@olenbetong/appframe-ds 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/package.json +8 -7
- package/scripts/copyAssets.mjs +23 -51
- package/src/AfLookup/Lookup.css +4 -0
- package/src/AfLookup/Lookup.tsx +24 -2
- package/src/cutoff/CutOffPicker.css +12 -0
- package/src/cutoff/CutOffPicker.tsx +165 -0
- package/src/cutoff/context.tsx +194 -0
- package/src/cutoff/dsCutOffDates.ts +21 -0
- package/src/cutoff/index.ts +4 -0
- package/src/cutoff/utils.test.ts +59 -0
- package/src/cutoff/utils.ts +276 -0
- package/src/grid/GridEditLookup.css +12 -0
- package/src/grid/GridEditLookup.tsx +121 -0
- package/src/grid/index.tsx +19 -0
- package/src/grid/useAfServerAggregation.test.ts +68 -0
- package/src/grid/useAfServerAggregation.ts +13 -0
- package/src/index.ts +2 -0
- package/src/layout/PageContainer.css +21 -2
- package/src/layout/PageContainer.tsx +18 -2
- package/src/layout/ResponsiveDialog.css +86 -0
- package/src/layout/ResponsiveDialog.tsx +55 -0
- package/src/layout/index.tsx +1 -0
- package/src/link/Link.tsx +23 -0
- package/src/link/index.ts +1 -0
- package/src/page/PageTitle.css +5 -0
- package/src/page/PageTitle.tsx +21 -0
- package/src/page/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @olenbetong/appframe-ds
|
|
2
2
|
|
|
3
|
+
## 1.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 16232b8: The 1.2.0 on npm was published from a checkout that predates its own changelog: it carries neither the cut-off family (`CutOffPicker`, `CutOffProvider`, `useCutOff`, the period helpers), `ResponsiveDialog`, `PageTitle`, `Link`, `GridEditLookup` and the `DataGrid` namespace on the grid entry, nor the `PageContainer` `maxWidth` fix and the server-side grid totals following the filters on paged data objects. This version is the first on npm with all of it.
|
|
8
|
+
|
|
9
|
+
## 1.2.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- b38f806: Three more `@olenbetong/appframe-mui` equivalents, so apps can drop that package: the cut-off period family (`CutOffPicker`, `CutOffProvider`, `useCutOff`, `cutOffToPlainDate`, `plainDateToCutOff`, `getPeriodLabel`, the range helpers and `PeriodType`), `ResponsiveDialog` (a Designsystemet `Dialog` that fills small screens, decided by CSS), and `PageTitle` (a Designsystemet `Heading`). Also `Link` (a Designsystemet link that navigates with react-router), `GridEditLookup` for `@olenbetong/appframe-ds/grid`, and MUI X re-exported from the grid entry as the `DataGrid` namespace, so a platform app never bundles its own copy. The cut-off utilities use Temporal for ISO weeks; the package now depends on `temporal-polyfill`.
|
|
14
|
+
- cd7fc4f: Add label tag support with optional and required indicators.
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- cc4bdf4: `PageContainer` honours its `maxWidth` prop again. The prop was accepted and dropped, so every page that asked for `sm`, `md`, `lg` or `xl` rendered full width; it now limits the content to the MUI Container scale (36/48/64/80rem plus the gutters), against the leading edge as before.
|
|
19
|
+
- 91838e7: Server-side grid totals now follow the filters on a dynamically loading data object. `useAfServerAggregation` reloaded only on `onDataLoaded`, which a paged data object never fires, so the totals kept the values from the first load; it now also reloads when a paged load starts over (`onPartialDataLoaded` with `skip` 0). `AfGrid` also passes MUI's default `getAggregationPosition`, the one prop that makes the grid re-aggregate when the aggregation functions change, so the footer shows the totals as they arrive instead of those of the previous load.
|
|
20
|
+
|
|
3
21
|
## 1.1.0
|
|
4
22
|
|
|
5
23
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olenbetong/appframe-ds",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Components that bind Designsystemet components to Appframe data objects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./es/index.d.ts",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
63
|
"@atlaskit/pragmatic-drag-and-drop": "3.1.0",
|
|
64
|
-
"@atlaskit/pragmatic-drag-and-drop-hitbox": "2.2.
|
|
64
|
+
"@atlaskit/pragmatic-drag-and-drop-hitbox": "2.2.2",
|
|
65
65
|
"@base-ui/react": "1.8.0",
|
|
66
66
|
"clsx": "^2.1.1",
|
|
67
67
|
"dockview-react": "8.3.1",
|
|
@@ -70,19 +70,20 @@
|
|
|
70
70
|
"react": "19.3.0",
|
|
71
71
|
"react-dom": "19.3.0",
|
|
72
72
|
"react-window": "2.3.1",
|
|
73
|
+
"temporal-polyfill": "1.0.5",
|
|
74
|
+
"@olenbetong/appframe-react": "2.2.1",
|
|
73
75
|
"@olenbetong/appframe-core": "2.14.0",
|
|
74
|
-
"@olenbetong/appframe-data": "1.7.2"
|
|
75
|
-
"@olenbetong/appframe-react": "2.2.1"
|
|
76
|
+
"@olenbetong/appframe-data": "1.7.2"
|
|
76
77
|
},
|
|
77
78
|
"devDependencies": {
|
|
78
|
-
"@digdir/designsystemet-react": "1.
|
|
79
|
+
"@digdir/designsystemet-react": "1.22.0",
|
|
79
80
|
"@emotion/react": "^11.14.0",
|
|
80
81
|
"@emotion/styled": "^11.14.1",
|
|
81
82
|
"@mui/material": "9.4.0",
|
|
82
83
|
"@mui/x-data-grid-premium": "8.29.3",
|
|
83
84
|
"@mui/x-license": "8.29.3",
|
|
84
85
|
"@navikt/aksel-icons": "8.16.2",
|
|
85
|
-
"@testing-library/dom": "^10.4.
|
|
86
|
+
"@testing-library/dom": "^10.4.2",
|
|
86
87
|
"@testing-library/jest-dom": "^7.0.1",
|
|
87
88
|
"@testing-library/react": "16.3.3",
|
|
88
89
|
"@testing-library/user-event": "^14.6.7",
|
|
@@ -90,7 +91,7 @@
|
|
|
90
91
|
"@types/react-dom": "19.3.0",
|
|
91
92
|
"happy-dom": "20.14.5",
|
|
92
93
|
"react-error-boundary": "6.1.5",
|
|
93
|
-
"react-router": "8.
|
|
94
|
+
"react-router": "8.4.0",
|
|
94
95
|
"typescript": "7.0.2"
|
|
95
96
|
},
|
|
96
97
|
"scripts": {
|
package/scripts/copyAssets.mjs
CHANGED
|
@@ -1,55 +1,27 @@
|
|
|
1
|
-
|
|
1
|
+
/*
|
|
2
|
+
* tsc emits the JavaScript into es/ but not the stylesheets the components import next to
|
|
3
|
+
* themselves. Copy every .css under src/ to the same path under es/, so a new component's
|
|
4
|
+
* stylesheet is published without anyone remembering a list.
|
|
5
|
+
*/
|
|
6
|
+
import { cpSync, mkdirSync, readdirSync } from "node:fs";
|
|
7
|
+
import { dirname, join, relative } from "node:path";
|
|
2
8
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
["src/layout/index.css", "es/layout/index.css"],
|
|
14
|
-
["src/layout/ErrorBoundary.css", "es/layout/ErrorBoundary.css"],
|
|
15
|
-
["src/layout/PageContainer.css", "es/layout/PageContainer.css"],
|
|
16
|
-
["src/page/Page.css", "es/page/Page.css"],
|
|
17
|
-
["src/page/PageBlock.css", "es/page/PageBlock.css"],
|
|
18
|
-
["src/page/PageHeader.css", "es/page/PageHeader.css"],
|
|
19
|
-
["src/list/ItemList.css", "es/list/ItemList.css"],
|
|
20
|
-
["src/input/InputAdornments.css", "es/input/InputAdornments.css"],
|
|
21
|
-
["src/paper/Paper.css", "es/paper/Paper.css"],
|
|
22
|
-
["src/progress/LinearProgress.css", "es/progress/LinearProgress.css"],
|
|
23
|
-
["src/sortable/DropIndicator.css", "es/sortable/DropIndicator.css"],
|
|
24
|
-
["src/sortable/SortableItemList.css", "es/sortable/SortableItemList.css"],
|
|
25
|
-
["src/autocomplete/Autocomplete.css", "es/autocomplete/Autocomplete.css"],
|
|
26
|
-
["src/filter/FilterBuilder.css", "es/filter/FilterBuilder.css"],
|
|
27
|
-
["src/filter/FieldFilterPanel.css", "es/filter/FieldFilterPanel.css"],
|
|
28
|
-
["src/filter/FilterEditor.css", "es/filter/FilterEditor.css"],
|
|
29
|
-
["src/filter/FilterNameDialog.css", "es/filter/FilterNameDialog.css"],
|
|
30
|
-
["src/filter/FilterShareDialog.css", "es/filter/FilterShareDialog.css"],
|
|
31
|
-
["src/filter/SavedFilterTree.css", "es/filter/SavedFilterTree.css"],
|
|
32
|
-
["src/grid/index.css", "es/grid/index.css"],
|
|
33
|
-
["src/grid/slots/slots.css", "es/grid/slots/slots.css"],
|
|
34
|
-
["src/mdi/Mdi.css", "es/mdi/Mdi.css"],
|
|
35
|
-
["src/report-downloader/ReportDownloader.css", "es/report-downloader/ReportDownloader.css"],
|
|
36
|
-
];
|
|
37
|
-
|
|
38
|
-
mkdirSync("es/layout", { recursive: true });
|
|
39
|
-
mkdirSync("es/input", { recursive: true });
|
|
40
|
-
mkdirSync("es/list", { recursive: true });
|
|
41
|
-
mkdirSync("es/page", { recursive: true });
|
|
42
|
-
mkdirSync("es/paper", { recursive: true });
|
|
43
|
-
mkdirSync("es/progress", { recursive: true });
|
|
44
|
-
mkdirSync("es/sortable", { recursive: true });
|
|
45
|
-
mkdirSync("es/autocomplete", { recursive: true });
|
|
46
|
-
mkdirSync("es/filter", { recursive: true });
|
|
47
|
-
mkdirSync("es/grid", { recursive: true });
|
|
48
|
-
mkdirSync("es/grid/slots", { recursive: true });
|
|
49
|
-
mkdirSync("es/mdi", { recursive: true });
|
|
50
|
-
mkdirSync("es/report-downloader", { recursive: true });
|
|
9
|
+
function* cssFiles(dir) {
|
|
10
|
+
for (let entry of readdirSync(dir, { withFileTypes: true })) {
|
|
11
|
+
let path = join(dir, entry.name);
|
|
12
|
+
if (entry.isDirectory()) {
|
|
13
|
+
yield* cssFiles(path);
|
|
14
|
+
} else if (entry.name.endsWith(".css")) {
|
|
15
|
+
yield path;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
51
19
|
|
|
52
|
-
|
|
20
|
+
let count = 0;
|
|
21
|
+
for (let src of cssFiles("src")) {
|
|
22
|
+
let dest = join("es", relative("src", src));
|
|
23
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
53
24
|
cpSync(src, dest);
|
|
54
|
-
|
|
25
|
+
count++;
|
|
55
26
|
}
|
|
27
|
+
console.log(`Copied ${count} stylesheets from src/ to es/`);
|
package/src/AfLookup/Lookup.css
CHANGED
package/src/AfLookup/Lookup.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import "./Lookup.css";
|
|
2
2
|
|
|
3
|
-
import { Button, Textfield, type TextfieldProps } from "@digdir/designsystemet-react";
|
|
3
|
+
import { Button, Tag, Textfield, type TextfieldProps } from "@digdir/designsystemet-react";
|
|
4
4
|
import { MagnifyingGlassIcon, XMarkIcon } from "@navikt/aksel-icons";
|
|
5
5
|
import { type DistributiveOmit, useLookupState } from "@olenbetong/appframe-react";
|
|
6
6
|
|
|
@@ -11,6 +11,26 @@ import { createPortal } from "react-dom";
|
|
|
11
11
|
import { LookupCombobox, type LookupComboboxProps } from "./LookupCombobox.js";
|
|
12
12
|
import { getLocalizedString } from "../i18n.js";
|
|
13
13
|
|
|
14
|
+
export type LabelTag = "optional" | "required";
|
|
15
|
+
|
|
16
|
+
const LABEL_TAG_COLOR: Record<LabelTag, string> = { optional: "info", required: "warning" };
|
|
17
|
+
const LABEL_TAG_TEXT: Record<LabelTag, string> = { optional: "Optional", required: "Required" };
|
|
18
|
+
|
|
19
|
+
function labelWithTag(label: string | undefined, labelTag: LabelTag | undefined) {
|
|
20
|
+
if (!labelTag) {
|
|
21
|
+
return label;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<>
|
|
26
|
+
{label}
|
|
27
|
+
<Tag className="ObLookup-labelTag" data-color={LABEL_TAG_COLOR[labelTag]} data-size="sm">
|
|
28
|
+
{getLocalizedString(LABEL_TAG_TEXT[labelTag])}
|
|
29
|
+
</Tag>
|
|
30
|
+
</>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
14
34
|
export type AfLookupProps<T extends Record<string, unknown>> = DistributiveOmit<LookupComboboxProps<T>, "value"> & {
|
|
15
35
|
/**
|
|
16
36
|
* Allows the user to enter a value manually that might not be in the list.
|
|
@@ -18,6 +38,7 @@ export type AfLookupProps<T extends Record<string, unknown>> = DistributiveOmit<
|
|
|
18
38
|
editable?: boolean;
|
|
19
39
|
fullWidth?: boolean;
|
|
20
40
|
label?: string;
|
|
41
|
+
labelTag?: LabelTag;
|
|
21
42
|
value?: string | null;
|
|
22
43
|
onChange?: (item: T | null) => void;
|
|
23
44
|
slotProps?: {
|
|
@@ -87,6 +108,7 @@ export function useLookup<T extends Record<string, unknown>>({
|
|
|
87
108
|
export function AfLookup<T extends Record<string, unknown>>({
|
|
88
109
|
fullWidth = false,
|
|
89
110
|
label,
|
|
111
|
+
labelTag,
|
|
90
112
|
slotProps,
|
|
91
113
|
value,
|
|
92
114
|
...props
|
|
@@ -121,7 +143,7 @@ export function AfLookup<T extends Record<string, unknown>>({
|
|
|
121
143
|
<Textfield
|
|
122
144
|
id={inputId}
|
|
123
145
|
value={value ?? ""}
|
|
124
|
-
label={label}
|
|
146
|
+
label={labelWithTag(label, labelTag)}
|
|
125
147
|
onClick={() => openDialog()}
|
|
126
148
|
onKeyDown={handleInputKeyDown}
|
|
127
149
|
className={clsx(slotProps?.textField?.className, "ObLookup-input")}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import "./CutOffPicker.css";
|
|
2
|
+
|
|
3
|
+
import { Select, Textfield } from "@digdir/designsystemet-react";
|
|
4
|
+
import clsx from "clsx";
|
|
5
|
+
import type { Temporal } from "temporal-polyfill";
|
|
6
|
+
|
|
7
|
+
import { getLocalizedString } from "../i18n.js";
|
|
8
|
+
import { useCutOff } from "./context.js";
|
|
9
|
+
import { type PeriodType, plainDateToCutOff } from "./utils.js";
|
|
10
|
+
|
|
11
|
+
export type CutOffPickerProps = {
|
|
12
|
+
className?: string;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
/** Period types left out of the type select. */
|
|
15
|
+
disabledTypes?: PeriodType[];
|
|
16
|
+
max?: Temporal.PlainDate | string;
|
|
17
|
+
min?: Temporal.PlainDate | string;
|
|
18
|
+
/** Designsystemet size of both fields. */
|
|
19
|
+
"data-size"?: "sm" | "md" | "lg";
|
|
20
|
+
/**
|
|
21
|
+
* Do not sync the `co` and `period` search parameters with the selection - for a form that
|
|
22
|
+
* submits the period itself.
|
|
23
|
+
*/
|
|
24
|
+
disableSearchParams?: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function cutOffTypes(): [PeriodType, string][] {
|
|
28
|
+
return [
|
|
29
|
+
["AllDates", getLocalizedString("Day")],
|
|
30
|
+
["WS", getLocalizedString("Week")],
|
|
31
|
+
["MLD", getLocalizedString("Month")],
|
|
32
|
+
["MLS", `${getLocalizedString("Month")} (${getLocalizedString("Last sunday")})`],
|
|
33
|
+
["QLD", getLocalizedString("Quarter")],
|
|
34
|
+
["QLS", `${getLocalizedString("Quarter")} (${getLocalizedString("Last sunday")})`],
|
|
35
|
+
["YLD", getLocalizedString("Year")],
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getCutOffTypes(disabledTypes: PeriodType[]): [PeriodType, string][] {
|
|
40
|
+
return cutOffTypes().filter((co) => !disabledTypes.includes(co[0]));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Picks the app's cut-off period: a select for the period type (day, week, month, quarter,
|
|
45
|
+
* year) and a field for the period itself, native date/week/month inputs where the browser
|
|
46
|
+
* has them and a select for quarters and years. Reads and writes the period through
|
|
47
|
+
* {@link useCutOff}, so it needs a `CutOffProvider` above it.
|
|
48
|
+
*/
|
|
49
|
+
export function CutOffPicker({
|
|
50
|
+
className,
|
|
51
|
+
max,
|
|
52
|
+
min,
|
|
53
|
+
disabled,
|
|
54
|
+
disabledTypes,
|
|
55
|
+
"data-size": size,
|
|
56
|
+
disableSearchParams = false,
|
|
57
|
+
}: CutOffPickerProps) {
|
|
58
|
+
let { cutOffType, periods, setPeriod, setCutOffType } = useCutOff({ disableSearchParams });
|
|
59
|
+
|
|
60
|
+
function handlePeriodChanged(evt: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
|
|
61
|
+
if (evt.target.value) {
|
|
62
|
+
setPeriod(cutOffType, evt.target.value);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let thisYear = new Date().getFullYear();
|
|
67
|
+
let quarters = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
|
68
|
+
.flatMap((dy) => [4, 3, 2, 1].map((q) => `${thisYear - dy}-Q${q}`))
|
|
69
|
+
.filter((period) => {
|
|
70
|
+
if (max && period > plainDateToCutOff(max, "QLD")) return false;
|
|
71
|
+
if (min && period < plainDateToCutOff(min, "QLD")) return false;
|
|
72
|
+
return true;
|
|
73
|
+
});
|
|
74
|
+
let years = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
|
|
75
|
+
.map((dy) => thisYear - dy)
|
|
76
|
+
.filter((year) => {
|
|
77
|
+
if (max && year > Number(plainDateToCutOff(max, "YLD"))) return false;
|
|
78
|
+
if (min && year < Number(plainDateToCutOff(min, "YLD"))) return false;
|
|
79
|
+
return true;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className={clsx("ObCutOffPicker-root", className)} data-size={size}>
|
|
84
|
+
<Select
|
|
85
|
+
name="co"
|
|
86
|
+
aria-label={getLocalizedString("Period type")}
|
|
87
|
+
value={cutOffType}
|
|
88
|
+
onChange={(evt) => setCutOffType(evt.target.value as PeriodType)}
|
|
89
|
+
disabled={disabled}
|
|
90
|
+
>
|
|
91
|
+
{getCutOffTypes(disabledTypes ?? []).map(([type, label]) => (
|
|
92
|
+
<Select.Option value={type} key={type}>
|
|
93
|
+
{label}
|
|
94
|
+
</Select.Option>
|
|
95
|
+
))}
|
|
96
|
+
</Select>
|
|
97
|
+
{cutOffType === "AllDates" && (
|
|
98
|
+
<Textfield
|
|
99
|
+
name="period"
|
|
100
|
+
aria-label={getLocalizedString("Day")}
|
|
101
|
+
type="date"
|
|
102
|
+
value={periods.AllDates}
|
|
103
|
+
onChange={handlePeriodChanged}
|
|
104
|
+
disabled={disabled}
|
|
105
|
+
max={max ? plainDateToCutOff(max, "AllDates") : undefined}
|
|
106
|
+
min={min ? plainDateToCutOff(min, "AllDates") : undefined}
|
|
107
|
+
/>
|
|
108
|
+
)}
|
|
109
|
+
{cutOffType === "WS" && (
|
|
110
|
+
<Textfield
|
|
111
|
+
name="period"
|
|
112
|
+
aria-label={getLocalizedString("Week")}
|
|
113
|
+
type="week"
|
|
114
|
+
value={periods.WS.indexOf("W") === -1 ? periods.WS.replace("-", "-W") : periods.WS}
|
|
115
|
+
onChange={handlePeriodChanged}
|
|
116
|
+
disabled={disabled}
|
|
117
|
+
max={max ? plainDateToCutOff(max, "WS") : undefined}
|
|
118
|
+
min={min ? plainDateToCutOff(min, "WS") : undefined}
|
|
119
|
+
/>
|
|
120
|
+
)}
|
|
121
|
+
{(cutOffType === "MLD" || cutOffType === "MLS") && (
|
|
122
|
+
<Textfield
|
|
123
|
+
name="period"
|
|
124
|
+
aria-label={getLocalizedString("Month")}
|
|
125
|
+
type="month"
|
|
126
|
+
value={periods.MLD}
|
|
127
|
+
onChange={handlePeriodChanged}
|
|
128
|
+
disabled={disabled}
|
|
129
|
+
max={max ? plainDateToCutOff(max, "MLD") : undefined}
|
|
130
|
+
min={min ? plainDateToCutOff(min, "MLD") : undefined}
|
|
131
|
+
/>
|
|
132
|
+
)}
|
|
133
|
+
{(cutOffType === "QLD" || cutOffType === "QLS") && (
|
|
134
|
+
<Select
|
|
135
|
+
name="period"
|
|
136
|
+
aria-label={getLocalizedString("Quarter")}
|
|
137
|
+
value={periods.QLD}
|
|
138
|
+
onChange={handlePeriodChanged}
|
|
139
|
+
disabled={disabled}
|
|
140
|
+
>
|
|
141
|
+
{quarters.map((quarter) => (
|
|
142
|
+
<Select.Option key={quarter} value={quarter}>
|
|
143
|
+
{quarter}
|
|
144
|
+
</Select.Option>
|
|
145
|
+
))}
|
|
146
|
+
</Select>
|
|
147
|
+
)}
|
|
148
|
+
{cutOffType === "YLD" && (
|
|
149
|
+
<Select
|
|
150
|
+
name="period"
|
|
151
|
+
aria-label={getLocalizedString("Year")}
|
|
152
|
+
value={periods.YLD}
|
|
153
|
+
onChange={handlePeriodChanged}
|
|
154
|
+
disabled={disabled}
|
|
155
|
+
>
|
|
156
|
+
{years.map((year) => (
|
|
157
|
+
<Select.Option key={year} value={String(year)}>
|
|
158
|
+
{year}
|
|
159
|
+
</Select.Option>
|
|
160
|
+
))}
|
|
161
|
+
</Select>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { createContext, useContext, useEffect, useReducer } from "react";
|
|
3
|
+
import { useSearchParams } from "react-router";
|
|
4
|
+
|
|
5
|
+
import { type CutOffDatesRecord, dsCutOffDates } from "./dsCutOffDates.js";
|
|
6
|
+
import {
|
|
7
|
+
defaultCutOffType,
|
|
8
|
+
defaultPeriod,
|
|
9
|
+
defaultPeriods,
|
|
10
|
+
getCutOffFromSearchParams,
|
|
11
|
+
getHistoryRangeStart,
|
|
12
|
+
getPeriodLabel,
|
|
13
|
+
type PeriodType,
|
|
14
|
+
type PeriodTypeValueMap,
|
|
15
|
+
} from "./utils.js";
|
|
16
|
+
|
|
17
|
+
export type PeriodState = {
|
|
18
|
+
/** The selected period of every type, so switching type and back keeps the choice. */
|
|
19
|
+
periods: PeriodTypeValueMap;
|
|
20
|
+
/** The selected period as Appframe stores it (weeks without the W). */
|
|
21
|
+
period: string;
|
|
22
|
+
/** The selected period in ISO form (weeks as `2026-W38`). */
|
|
23
|
+
isoPeriod: string;
|
|
24
|
+
cutOffType: PeriodType;
|
|
25
|
+
/** Start of a history range ending at the period; see `getHistoryRangeStart`. */
|
|
26
|
+
historyStart: string;
|
|
27
|
+
label: string;
|
|
28
|
+
/** The period's dates from the database, once fetched (`CutOffProvider fetchPeriod`). */
|
|
29
|
+
data?: CutOffDatesRecord;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type PeriodActions = {
|
|
33
|
+
setPeriod: (type: PeriodType, period: string) => void;
|
|
34
|
+
setCutOffType: (type: PeriodType) => void;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const CutOffContext = createContext<PeriodState & PeriodActions>({
|
|
38
|
+
historyStart: getHistoryRangeStart(defaultCutOffType, defaultPeriod),
|
|
39
|
+
label: getPeriodLabel(defaultCutOffType, defaultPeriod),
|
|
40
|
+
periods: defaultPeriods,
|
|
41
|
+
period: defaultPeriod,
|
|
42
|
+
isoPeriod: defaultPeriod,
|
|
43
|
+
cutOffType: defaultCutOffType,
|
|
44
|
+
setPeriod: () => null,
|
|
45
|
+
setCutOffType: () => null,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
type CutOffAction =
|
|
49
|
+
| { type: "setCutOffType"; payload: PeriodType }
|
|
50
|
+
| { type: "setPeriod"; payload: { type: PeriodType; period: string } }
|
|
51
|
+
| { type: "cutOffLoaded"; payload: CutOffDatesRecord };
|
|
52
|
+
|
|
53
|
+
const periodCache = new Map<string, CutOffDatesRecord>();
|
|
54
|
+
|
|
55
|
+
function cutOffReducer(state: PeriodState, action: CutOffAction): PeriodState {
|
|
56
|
+
switch (action.type) {
|
|
57
|
+
case "setPeriod": {
|
|
58
|
+
let { period, type } = action.payload;
|
|
59
|
+
let isActivePeriod = type === state.cutOffType;
|
|
60
|
+
let key = `${type}-${period}`;
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
...state,
|
|
64
|
+
data: periodCache.get(key),
|
|
65
|
+
period: (isActivePeriod ? action.payload.period : state.period).replace("W", ""),
|
|
66
|
+
isoPeriod: isActivePeriod ? action.payload.period : state.period,
|
|
67
|
+
periods: {
|
|
68
|
+
...state.periods,
|
|
69
|
+
[state.cutOffType]: action.payload.period,
|
|
70
|
+
},
|
|
71
|
+
historyStart: isActivePeriod ? getHistoryRangeStart(type, period) : state.historyStart,
|
|
72
|
+
label: isActivePeriod ? getPeriodLabel(type, period) : state.label,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
case "setCutOffType": {
|
|
76
|
+
let { payload: coType } = action;
|
|
77
|
+
let key = `${coType}-${state.periods[coType]}`;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
...state,
|
|
81
|
+
data: periodCache.get(key),
|
|
82
|
+
period: state.periods[coType].replace("W", ""),
|
|
83
|
+
isoPeriod: state.periods[coType],
|
|
84
|
+
historyStart: getHistoryRangeStart(coType, state.periods[coType]),
|
|
85
|
+
label: getPeriodLabel(coType, state.periods[coType]),
|
|
86
|
+
cutOffType: coType,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
case "cutOffLoaded":
|
|
90
|
+
return { ...state, data: action.payload };
|
|
91
|
+
default:
|
|
92
|
+
return state;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function initialState(): PeriodState {
|
|
97
|
+
let { coType, period } = getCutOffFromSearchParams(new URLSearchParams(globalThis.location?.search ?? ""));
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
periods: { ...defaultPeriods, [coType]: period },
|
|
101
|
+
historyStart: getHistoryRangeStart(coType, period),
|
|
102
|
+
label: getPeriodLabel(coType, period),
|
|
103
|
+
period,
|
|
104
|
+
isoPeriod: period,
|
|
105
|
+
cutOffType: coType,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function getCutOffPeriod(cutOffType: PeriodType, period: string): Promise<CutOffDatesRecord | undefined> {
|
|
110
|
+
let key = `${cutOffType}-${period}`;
|
|
111
|
+
let cached = periodCache.get(key);
|
|
112
|
+
if (cached) return cached;
|
|
113
|
+
if (!dsCutOffDates) return undefined;
|
|
114
|
+
|
|
115
|
+
let result = await dsCutOffDates.retrieve({
|
|
116
|
+
maxRecords: 1,
|
|
117
|
+
filterString: `[COType] = '${cutOffType}' AND [Period] = '${period}'`,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (result.length > 0) {
|
|
121
|
+
periodCache.set(key, result[0]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return result[0];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type CutOffProviderProps = React.PropsWithChildren<{
|
|
128
|
+
/**
|
|
129
|
+
* Fetch the period's dates from the database when it changes. Without it `data` stays
|
|
130
|
+
* undefined.
|
|
131
|
+
*/
|
|
132
|
+
fetchPeriod?: boolean;
|
|
133
|
+
}>;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Holds the selected cut-off period for the app, initialised from the `co` and `period`
|
|
137
|
+
* search parameters. Read and change it with {@link useCutOff}; let the user pick it with
|
|
138
|
+
* `CutOffPicker`.
|
|
139
|
+
*/
|
|
140
|
+
export function CutOffProvider({ children, fetchPeriod }: CutOffProviderProps) {
|
|
141
|
+
let [state, dispatch] = useReducer(cutOffReducer, undefined, initialState);
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (fetchPeriod && !state.data) {
|
|
145
|
+
getCutOffPeriod(state.cutOffType, state.period)
|
|
146
|
+
.then((payload) => {
|
|
147
|
+
if (payload) dispatch({ type: "cutOffLoaded", payload });
|
|
148
|
+
})
|
|
149
|
+
.catch((error) => console.warn(String(error)));
|
|
150
|
+
}
|
|
151
|
+
}, [fetchPeriod, state.cutOffType, state.period, state.data]);
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<CutOffContext.Provider
|
|
155
|
+
value={{
|
|
156
|
+
...state,
|
|
157
|
+
setPeriod: (type, period) => dispatch({ type: "setPeriod", payload: { type, period } }),
|
|
158
|
+
setCutOffType: (type) => dispatch({ type: "setCutOffType", payload: type }),
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
{children}
|
|
162
|
+
</CutOffContext.Provider>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The selected cut-off period and the actions that change it. Keeps the `co` and `period`
|
|
168
|
+
* search parameters in step with the selection unless `disableSearchParams` is set (a form
|
|
169
|
+
* that submits the period itself, say).
|
|
170
|
+
*/
|
|
171
|
+
export function useCutOff({ disableSearchParams = false }: { disableSearchParams?: boolean } = {}) {
|
|
172
|
+
let cutOff = useContext(CutOffContext);
|
|
173
|
+
let [, setSearchParams] = useSearchParams();
|
|
174
|
+
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (!disableSearchParams) {
|
|
177
|
+
let searchParams = new URLSearchParams(window.location.search);
|
|
178
|
+
if (cutOff.period === defaultPeriods[cutOff.cutOffType]) {
|
|
179
|
+
searchParams.delete("period");
|
|
180
|
+
} else {
|
|
181
|
+
searchParams.set("period", cutOff.period);
|
|
182
|
+
}
|
|
183
|
+
if (cutOff.cutOffType === "MLD") {
|
|
184
|
+
searchParams.delete("co");
|
|
185
|
+
} else {
|
|
186
|
+
searchParams.set("co", cutOff.cutOffType);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
setSearchParams(searchParams, { replace: true });
|
|
190
|
+
}
|
|
191
|
+
}, [disableSearchParams, cutOff.period, cutOff.cutOffType, setSearchParams]);
|
|
192
|
+
|
|
193
|
+
return cutOff;
|
|
194
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { AfData } from "@olenbetong/appframe-data";
|
|
2
|
+
|
|
3
|
+
export type CutOffDatesRecord = {
|
|
4
|
+
COType: string;
|
|
5
|
+
PeriodStart: Date;
|
|
6
|
+
PeriodEnd: Date;
|
|
7
|
+
Period: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** `aviw_System_CutOffDates`: the start and end dates the database assigns to a period. */
|
|
11
|
+
export const dsCutOffDates = (
|
|
12
|
+
(globalThis as { af?: { data?: AfData } }).af?.data as AfData | undefined
|
|
13
|
+
)?.generateApiDataHandler?.<CutOffDatesRecord>({
|
|
14
|
+
resource: "aviw_System_CutOffDates",
|
|
15
|
+
fields: [
|
|
16
|
+
{ name: "COType", type: "string", nullable: false },
|
|
17
|
+
{ name: "PeriodStart", type: "datetime", nullable: true },
|
|
18
|
+
{ name: "PeriodEnd", type: "datetime", nullable: true },
|
|
19
|
+
{ name: "Period", type: "string", nullable: true },
|
|
20
|
+
],
|
|
21
|
+
});
|