@olenbetong/appframe-ds 1.0.3 → 1.2.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 +13 -0
- package/package.json +8 -7
- package/scripts/copyAssets.mjs +27 -55
- package/src/AfLookup/Lookup.css +129 -125
- package/src/AfLookup/Lookup.tsx +184 -162
- package/src/AfLookup/LookupCombobox.css +78 -78
- package/src/AfLookup/LookupCombobox.tsx +237 -237
- package/src/AfLookup/LookupEdit.tsx +130 -130
- package/src/AfLookup/LookupGrid.tsx +124 -124
- package/src/AfLookup/index.ts +5 -5
- package/src/autocomplete/Autocomplete.css +256 -256
- package/src/autocomplete/Autocomplete.tsx +320 -320
- package/src/autocomplete/AutocompleteFrame.tsx +168 -168
- package/src/autocomplete/Combobox.tsx +286 -286
- package/src/autocomplete/index.ts +4 -4
- package/src/autocomplete/types.ts +204 -204
- package/src/autocomplete/utils.ts +183 -183
- package/src/binding/BoundNumericTextField.tsx +102 -102
- package/src/binding/BoundTextField.tsx +49 -49
- package/src/binding/CancelButton.tsx +30 -30
- package/src/binding/CancelIconButton.tsx +30 -30
- package/src/binding/DataEditToolbar.tsx +92 -92
- package/src/binding/DeleteButton.tsx +35 -35
- package/src/binding/DeleteIconButton.tsx +35 -35
- package/src/binding/RefreshButton.tsx +31 -31
- package/src/binding/RefreshIconButton.tsx +33 -33
- package/src/binding/RefreshRowIconButton.tsx +33 -33
- package/src/binding/SaveButton.tsx +37 -37
- package/src/binding/SaveIconButton.tsx +38 -38
- package/src/container/Container.tsx +49 -49
- package/src/container/index.ts +1 -1
- 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/filter/ChipListInput.tsx +79 -79
- package/src/filter/FieldFilterPanel.css +150 -150
- package/src/filter/FieldFilterPanel.tsx +309 -309
- package/src/filter/FilterBuilder.css +152 -152
- package/src/filter/FilterBuilder.test.tsx +153 -153
- package/src/filter/FilterBuilder.tsx +435 -435
- package/src/filter/FilterEditor.css +231 -231
- package/src/filter/FilterEditor.test.tsx +259 -259
- package/src/filter/FilterEditor.tsx +75 -74
- package/src/filter/FilterGroupEditor.tsx +160 -160
- package/src/filter/FilterNameDialog.css +22 -22
- package/src/filter/FilterNameDialog.tsx +81 -81
- package/src/filter/FilterRow.tsx +117 -117
- package/src/filter/FilterShareDialog.css +28 -28
- package/src/filter/FilterShareDialog.tsx +201 -201
- package/src/filter/FilterStringField.tsx +78 -77
- package/src/filter/FilterValueEditor.tsx +220 -220
- package/src/filter/SavedFilterTree.css +107 -107
- package/src/filter/SavedFilterTree.test.tsx +94 -94
- package/src/filter/SavedFilterTree.tsx +218 -222
- package/src/filter/fieldFilter.test.ts +158 -158
- package/src/filter/fieldFilter.ts +139 -139
- package/src/filter/index.ts +27 -27
- package/src/filter/types.ts +38 -38
- package/src/filter/useDistinctValues.test.ts +102 -102
- package/src/filter/useDistinctValues.ts +230 -230
- package/src/global.d.ts +11 -11
- package/src/grid/AfGridColumnsPanel.tsx +118 -118
- package/src/grid/AfGridContext.tsx +51 -51
- package/src/grid/AfGridError.tsx +46 -46
- package/src/grid/AfHeaderFilterCell.tsx +147 -151
- package/src/grid/AfHeaderFilterPanel.tsx +111 -111
- package/src/grid/GridEditLookup.css +12 -0
- package/src/grid/GridEditLookup.tsx +121 -0
- package/src/grid/Toolbar.tsx +233 -233
- package/src/grid/editing.ts +100 -100
- package/src/grid/filter.ts +78 -78
- package/src/grid/formatters.ts +48 -48
- package/src/grid/index.css +245 -245
- package/src/grid/index.tsx +489 -484
- package/src/grid/license.ts +48 -48
- package/src/grid/localization.ts +369 -369
- package/src/grid/slots/index.tsx +307 -307
- package/src/grid/slots/slots.css +64 -64
- package/src/grid/theme.tsx +138 -138
- package/src/grid/useAfColumns.tsx +371 -371
- package/src/grid/useAfCurrentIndex.ts +63 -63
- package/src/grid/useAfData.ts +20 -20
- package/src/grid/useAfFilter.ts +160 -160
- package/src/grid/useAfFilterFields.ts +55 -55
- package/src/grid/useAfGridApi.ts +71 -71
- package/src/grid/useAfKeyBindings.ts +36 -36
- package/src/grid/useAfNewItemRow.ts +258 -258
- package/src/grid/useAfPagination.ts +53 -53
- package/src/grid/useAfPersistedState.ts +230 -230
- package/src/grid/useAfRowEditModel.ts +145 -145
- package/src/grid/useAfRowGrouping.ts +47 -47
- package/src/grid/useAfServerAggregation.ts +145 -145
- package/src/grid/useAfSortModel.ts +84 -84
- package/src/i18n.ts +13 -0
- package/src/index.ts +14 -12
- package/src/input/InputAdornments.css +75 -75
- package/src/input/InputAdornments.tsx +102 -102
- package/src/input/index.ts +1 -1
- package/src/layout/AppMenu.tsx +74 -75
- package/src/layout/AppMenuDrawer.css +70 -70
- package/src/layout/AppMenuDrawer.tsx +122 -122
- package/src/layout/ErrorBoundary.tsx +162 -162
- package/src/layout/ResponsiveDialog.css +86 -0
- package/src/layout/ResponsiveDialog.tsx +55 -0
- package/src/layout/index.tsx +69 -68
- package/src/link/Link.tsx +23 -0
- package/src/link/index.ts +1 -0
- package/src/mdi/Mdi.css +138 -138
- package/src/mdi/Mdi.test.tsx +91 -87
- package/src/mdi/Mdi.tsx +141 -141
- package/src/mdi/MdiConfirmCloseDialog.tsx +47 -47
- package/src/mdi/MdiContext.tsx +59 -59
- package/src/mdi/MdiDocumentContext.tsx +60 -60
- package/src/mdi/MdiPanel.tsx +61 -61
- package/src/mdi/MdiRestore.test.tsx +86 -82
- package/src/mdi/MdiTab.tsx +30 -30
- package/src/mdi/controller.test.ts +382 -382
- package/src/mdi/controller.ts +656 -656
- package/src/mdi/dockviewHost.ts +132 -132
- package/src/mdi/fakeHost.ts +200 -200
- package/src/mdi/host.ts +44 -44
- package/src/mdi/ids.test.ts +20 -20
- package/src/mdi/ids.ts +18 -18
- package/src/mdi/index.ts +31 -31
- package/src/mdi/layoutRestore.test.ts +156 -156
- package/src/mdi/layoutRestore.ts +311 -311
- package/src/mdi/layoutSpec.test.ts +143 -143
- package/src/mdi/layoutSpec.ts +214 -214
- package/src/mdi/layoutStore.test.ts +93 -93
- package/src/mdi/layoutStore.ts +99 -99
- package/src/mdi/memorySettingsStore.ts +21 -21
- package/src/mdi/registry.ts +34 -34
- package/src/mdi/testTypes.ts +37 -37
- package/src/mdi/theme.ts +22 -22
- package/src/mdi/types.ts +214 -214
- package/src/page/Page.css +53 -53
- package/src/page/PageBlock.css +64 -64
- package/src/page/PageBlock.tsx +92 -92
- package/src/page/PageHeader.css +88 -88
- package/src/page/PageHeader.tsx +115 -116
- package/src/page/PageTitle.css +5 -0
- package/src/page/PageTitle.tsx +21 -0
- package/src/page/index.ts +4 -3
- package/src/paper/Paper.tsx +52 -52
- package/src/progress/LinearProgress.css +93 -93
- package/src/progress/LinearProgress.tsx +104 -104
- package/src/progress/index.ts +1 -1
- package/src/report-downloader/ReportDownloader.css +67 -67
- package/src/report-downloader/components/StatusItem.tsx +87 -86
- package/src/report-downloader/components/StatusList.tsx +28 -28
- package/src/report-downloader/index.ts +41 -41
- package/src/report-downloader/status.tsx +92 -92
- package/src/sortable/DropIndicator.css +51 -51
- package/src/sortable/DropIndicator.tsx +27 -27
- package/src/sortable/SortableItemList.css +21 -21
- package/src/sortable/SortableItemList.tsx +264 -264
- package/src/sortable/index.ts +8 -8
- package/src/sortable/reorderSortOrder.ts +126 -126
- package/src/sortable/sortOrder.ts +101 -101
- package/src/sortable/types.ts +9 -9
- package/src/sortable/useSortableContainer.ts +53 -53
- package/src/sortable/useSortableItem.ts +95 -95
- package/src/sortable/useSortableList.ts +117 -117
- package/src/test/happy-dom-document-class.ts +24 -24
- package/src/test/setup.ts +12 -12
- package/src/theme/useDsColorScheme.ts +53 -53
- package/tsconfig.build.json +9 -9
- package/tsconfig.json +8 -8
- package/vitest.config.ts +10 -10
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
import { Badge, BadgePosition, Button, type ButtonProps } from "@digdir/designsystemet-react";
|
|
2
|
-
import { FloppydiskIcon } from "@navikt/aksel-icons";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
export const SaveButton: ForwardRefExoticComponent<ButtonProps & RefAttributes<HTMLButtonElement>> = forwardRef<
|
|
8
|
-
HTMLButtonElement,
|
|
9
|
-
ButtonProps
|
|
10
|
-
>(function SaveButton({ onClick, children, loading, ...props }, ref) {
|
|
11
|
-
let { dirty, saving, save } = useSaveButton();
|
|
12
|
-
let isLoading = saving || loading;
|
|
13
|
-
|
|
14
|
-
return (
|
|
15
|
-
<Button
|
|
16
|
-
loading={isLoading}
|
|
17
|
-
onClick={(evt) => {
|
|
18
|
-
onClick?.(evt);
|
|
19
|
-
if (!evt.defaultPrevented) {
|
|
20
|
-
save();
|
|
21
|
-
}
|
|
22
|
-
}}
|
|
23
|
-
{...props}
|
|
24
|
-
ref={ref}
|
|
25
|
-
>
|
|
26
|
-
{/* Icon is hidden while loading so the spinner takes its place instead of adding width */}
|
|
27
|
-
{!isLoading && (
|
|
28
|
-
<BadgePosition placement="top-right" overlap="circle">
|
|
29
|
-
<FloppydiskIcon aria-hidden="true" />
|
|
30
|
-
{/* Indicates unsaved changes on the current record */}
|
|
31
|
-
{dirty && <Badge data-color="warning" aria-hidden="true" />}
|
|
32
|
-
</BadgePosition>
|
|
33
|
-
)}
|
|
34
|
-
{children ?? getLocalizedString("Save")}
|
|
35
|
-
</Button>
|
|
36
|
-
);
|
|
37
|
-
});
|
|
1
|
+
import { Badge, BadgePosition, Button, type ButtonProps } from "@digdir/designsystemet-react";
|
|
2
|
+
import { FloppydiskIcon } from "@navikt/aksel-icons";
|
|
3
|
+
import { useSaveButton } from "@olenbetong/appframe-react";
|
|
4
|
+
import { type ForwardRefExoticComponent, forwardRef, type RefAttributes } from "react";
|
|
5
|
+
import { getLocalizedString } from "../i18n.js";
|
|
6
|
+
|
|
7
|
+
export const SaveButton: ForwardRefExoticComponent<ButtonProps & RefAttributes<HTMLButtonElement>> = forwardRef<
|
|
8
|
+
HTMLButtonElement,
|
|
9
|
+
ButtonProps
|
|
10
|
+
>(function SaveButton({ onClick, children, loading, ...props }, ref) {
|
|
11
|
+
let { dirty, saving, save } = useSaveButton();
|
|
12
|
+
let isLoading = saving || loading;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<Button
|
|
16
|
+
loading={isLoading}
|
|
17
|
+
onClick={(evt) => {
|
|
18
|
+
onClick?.(evt);
|
|
19
|
+
if (!evt.defaultPrevented) {
|
|
20
|
+
save();
|
|
21
|
+
}
|
|
22
|
+
}}
|
|
23
|
+
{...props}
|
|
24
|
+
ref={ref}
|
|
25
|
+
>
|
|
26
|
+
{/* Icon is hidden while loading so the spinner takes its place instead of adding width */}
|
|
27
|
+
{!isLoading && (
|
|
28
|
+
<BadgePosition placement="top-right" overlap="circle">
|
|
29
|
+
<FloppydiskIcon aria-hidden="true" />
|
|
30
|
+
{/* Indicates unsaved changes on the current record */}
|
|
31
|
+
{dirty && <Badge data-color="warning" aria-hidden="true" />}
|
|
32
|
+
</BadgePosition>
|
|
33
|
+
)}
|
|
34
|
+
{children ?? getLocalizedString("Save")}
|
|
35
|
+
</Button>
|
|
36
|
+
);
|
|
37
|
+
});
|
|
@@ -1,38 +1,38 @@
|
|
|
1
|
-
import { Badge, BadgePosition, Button, type ButtonProps } from "@digdir/designsystemet-react";
|
|
2
|
-
import { FloppydiskIcon } from "@navikt/aksel-icons";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
export const SaveIconButton = forwardRef<HTMLButtonElement, Omit<ButtonProps, "icon">>(function SaveIconButton(
|
|
8
|
-
{ onClick, children, loading, ...props },
|
|
9
|
-
ref,
|
|
10
|
-
) {
|
|
11
|
-
let { dirty, saving, save } = useSaveButton();
|
|
12
|
-
let isLoading = saving || loading;
|
|
13
|
-
|
|
14
|
-
return (
|
|
15
|
-
<Button
|
|
16
|
-
icon
|
|
17
|
-
loading={isLoading}
|
|
18
|
-
aria-label={getLocalizedString("Save")}
|
|
19
|
-
variant="tertiary"
|
|
20
|
-
onClick={(evt) => {
|
|
21
|
-
onClick?.(evt);
|
|
22
|
-
if (!evt.defaultPrevented) {
|
|
23
|
-
save();
|
|
24
|
-
}
|
|
25
|
-
}}
|
|
26
|
-
{...props}
|
|
27
|
-
ref={ref}
|
|
28
|
-
>
|
|
29
|
-
{children ?? (
|
|
30
|
-
<BadgePosition placement="top-right" overlap="circle">
|
|
31
|
-
<FloppydiskIcon />
|
|
32
|
-
{/* Indicates unsaved changes on the current record */}
|
|
33
|
-
{dirty && <Badge data-color="warning" aria-hidden="true" />}
|
|
34
|
-
</BadgePosition>
|
|
35
|
-
)}
|
|
36
|
-
</Button>
|
|
37
|
-
);
|
|
38
|
-
});
|
|
1
|
+
import { Badge, BadgePosition, Button, type ButtonProps } from "@digdir/designsystemet-react";
|
|
2
|
+
import { FloppydiskIcon } from "@navikt/aksel-icons";
|
|
3
|
+
import { useSaveButton } from "@olenbetong/appframe-react";
|
|
4
|
+
import { forwardRef } from "react";
|
|
5
|
+
import { getLocalizedString } from "../i18n.js";
|
|
6
|
+
|
|
7
|
+
export const SaveIconButton = forwardRef<HTMLButtonElement, Omit<ButtonProps, "icon">>(function SaveIconButton(
|
|
8
|
+
{ onClick, children, loading, ...props },
|
|
9
|
+
ref,
|
|
10
|
+
) {
|
|
11
|
+
let { dirty, saving, save } = useSaveButton();
|
|
12
|
+
let isLoading = saving || loading;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<Button
|
|
16
|
+
icon
|
|
17
|
+
loading={isLoading}
|
|
18
|
+
aria-label={getLocalizedString("Save")}
|
|
19
|
+
variant="tertiary"
|
|
20
|
+
onClick={(evt) => {
|
|
21
|
+
onClick?.(evt);
|
|
22
|
+
if (!evt.defaultPrevented) {
|
|
23
|
+
save();
|
|
24
|
+
}
|
|
25
|
+
}}
|
|
26
|
+
{...props}
|
|
27
|
+
ref={ref}
|
|
28
|
+
>
|
|
29
|
+
{children ?? (
|
|
30
|
+
<BadgePosition placement="top-right" overlap="circle">
|
|
31
|
+
<FloppydiskIcon />
|
|
32
|
+
{/* Indicates unsaved changes on the current record */}
|
|
33
|
+
{dirty && <Badge data-color="warning" aria-hidden="true" />}
|
|
34
|
+
</BadgePosition>
|
|
35
|
+
)}
|
|
36
|
+
</Button>
|
|
37
|
+
);
|
|
38
|
+
});
|
|
@@ -1,49 +1,49 @@
|
|
|
1
|
-
import { PageBlock, type PageBlockProps, type PageBlockWidth } from "../page/PageBlock.js";
|
|
2
|
-
|
|
3
|
-
export const containerMaxWidths = ["xs", "sm", "md", "lg", "xl"] as const;
|
|
4
|
-
|
|
5
|
-
export type ContainerMaxWidth = (typeof containerMaxWidths)[number];
|
|
6
|
-
|
|
7
|
-
const maxWidthToBlockWidth: Record<ContainerMaxWidth, PageBlockWidth> = {
|
|
8
|
-
xs: "text",
|
|
9
|
-
sm: "text",
|
|
10
|
-
md: "md",
|
|
11
|
-
lg: "lg",
|
|
12
|
-
xl: "xl",
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
export type ContainerProps = Omit<PageBlockProps, "width"> & {
|
|
16
|
-
/**
|
|
17
|
-
* Predefined max-width, using the same scale as MUI `Container`. Content is
|
|
18
|
-
* horizontally centered.
|
|
19
|
-
*
|
|
20
|
-
* - `xs`/`sm`: 36rem (576px) — recommended line-length for running text
|
|
21
|
-
* - `md`: 48rem (768px)
|
|
22
|
-
* - `lg`: 64rem (1024px)
|
|
23
|
-
* - `xl`: 80rem (1280px)
|
|
24
|
-
* - `false`: no max-width
|
|
25
|
-
*
|
|
26
|
-
* @default "lg"
|
|
27
|
-
*/
|
|
28
|
-
maxWidth?: ContainerMaxWidth | false;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Centered, max-width content wrapper — the MUI `Container` equivalent.
|
|
33
|
-
*
|
|
34
|
-
* A thin alias over `Page.Block` with gutters enabled by default, for the very
|
|
35
|
-
* common case of "center this content and give it sensible side padding"
|
|
36
|
-
* without having to reach for the full `Page` composition.
|
|
37
|
-
*
|
|
38
|
-
* @example
|
|
39
|
-
* ```jsx
|
|
40
|
-
* <Container maxWidth="sm">
|
|
41
|
-
* <Heading>Expenses</Heading>
|
|
42
|
-
* </Container>
|
|
43
|
-
* ```
|
|
44
|
-
*/
|
|
45
|
-
export function Container({ gutters = true, maxWidth = "lg", ...props }: ContainerProps) {
|
|
46
|
-
let width = maxWidth === false ? undefined : maxWidthToBlockWidth[maxWidth];
|
|
47
|
-
|
|
48
|
-
return <PageBlock gutters={gutters} width={width} {...props} />;
|
|
49
|
-
}
|
|
1
|
+
import { PageBlock, type PageBlockProps, type PageBlockWidth } from "../page/PageBlock.js";
|
|
2
|
+
|
|
3
|
+
export const containerMaxWidths = ["xs", "sm", "md", "lg", "xl"] as const;
|
|
4
|
+
|
|
5
|
+
export type ContainerMaxWidth = (typeof containerMaxWidths)[number];
|
|
6
|
+
|
|
7
|
+
const maxWidthToBlockWidth: Record<ContainerMaxWidth, PageBlockWidth> = {
|
|
8
|
+
xs: "text",
|
|
9
|
+
sm: "text",
|
|
10
|
+
md: "md",
|
|
11
|
+
lg: "lg",
|
|
12
|
+
xl: "xl",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ContainerProps = Omit<PageBlockProps, "width"> & {
|
|
16
|
+
/**
|
|
17
|
+
* Predefined max-width, using the same scale as MUI `Container`. Content is
|
|
18
|
+
* horizontally centered.
|
|
19
|
+
*
|
|
20
|
+
* - `xs`/`sm`: 36rem (576px) — recommended line-length for running text
|
|
21
|
+
* - `md`: 48rem (768px)
|
|
22
|
+
* - `lg`: 64rem (1024px)
|
|
23
|
+
* - `xl`: 80rem (1280px)
|
|
24
|
+
* - `false`: no max-width
|
|
25
|
+
*
|
|
26
|
+
* @default "lg"
|
|
27
|
+
*/
|
|
28
|
+
maxWidth?: ContainerMaxWidth | false;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Centered, max-width content wrapper — the MUI `Container` equivalent.
|
|
33
|
+
*
|
|
34
|
+
* A thin alias over `Page.Block` with gutters enabled by default, for the very
|
|
35
|
+
* common case of "center this content and give it sensible side padding"
|
|
36
|
+
* without having to reach for the full `Page` composition.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```jsx
|
|
40
|
+
* <Container maxWidth="sm">
|
|
41
|
+
* <Heading>Expenses</Heading>
|
|
42
|
+
* </Container>
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function Container({ gutters = true, maxWidth = "lg", ...props }: ContainerProps) {
|
|
46
|
+
let width = maxWidth === false ? undefined : maxWidthToBlockWidth[maxWidth];
|
|
47
|
+
|
|
48
|
+
return <PageBlock gutters={gutters} width={width} {...props} />;
|
|
49
|
+
}
|
package/src/container/index.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "./Container.js";
|
|
1
|
+
export * from "./Container.js";
|
|
@@ -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
|
+
});
|