(isNull ? '' : String(initHuman.value));
- /* eslint-disable react-hooks/set-state-in-effect */
- useEffect(() => {
+ useResetOnChange(value, () => {
const h = msToHuman(typeof value === 'number' ? value : 0);
setUnit(h.unit);
setLocalStr(value == null ? '' : String(h.value));
- }, [value]);
- /* eslint-enable react-hooks/set-state-in-effect */
+ });
const commit = () => {
if (localStr === '') {
@@ -862,13 +851,7 @@ function DateTimeField({ value, onChange, readOnly, nullable }: DateTimeFieldPro
return new Date(local).toISOString();
};
- const [local, setLocal] = useState(() => toLocal(strValue));
-
- /* eslint-disable react-hooks/set-state-in-effect */
- useEffect(() => {
- setLocal(toLocal(strValue));
- }, [strValue]);
- /* eslint-enable react-hooks/set-state-in-effect */
+ const [local, setLocal] = useBufferedValue(strValue, toLocal);
const commit = () => {
const iso = toIso(local);
@@ -1005,9 +988,9 @@ function BlobField({ value, onChange, readOnly }: BlobFieldProps) {
if (!blobId || loaded) return;
let cancelled = false;
- setLoading(true);
(async () => {
+ setLoading(true);
try {
const accountId = getAccountId('x:Blob');
const responses = await jmapGet('Blob', accountId, [blobId], ['data:asText']);
@@ -1253,12 +1236,8 @@ function RateField({ value, onChange, readOnly, nullable }: RateFieldProps) {
const [localCount, setLocalCount] = useState(String(count));
const [localPeriod, setLocalPeriod] = useState(String(human.value));
- useEffect(() => {
- setLocalCount(String(count));
- }, [count]);
- useEffect(() => {
- setLocalPeriod(String(human.value));
- }, [human.value]);
+ useResetOnChange(count, () => setLocalCount(String(count)));
+ useResetOnChange(human.value, () => setLocalPeriod(String(human.value)));
const commitCount = () => {
const n = parseInt(localCount, 10);
diff --git a/src/components/layout/TopBar.tsx b/src/components/layout/TopBar.tsx
index 739cee0..a031f14 100644
--- a/src/components/layout/TopBar.tsx
+++ b/src/components/layout/TopBar.tsx
@@ -21,6 +21,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import Logo from '@/components/common/Logo';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { EnterpriseUpsell } from '@/components/common/EnterpriseUpsell';
import { findFirstAccessibleLinkInLayout, findFirstVisibleLinkInLayout, visibleLayouts } from '@/lib/layout';
import { useUIStore } from '@/stores/uiStore';
@@ -66,9 +67,18 @@ export function TopBar() {
-
-
-
+
+
+
+
+
+
+
+
+ {t('version.label', 'Stalwart WebUI v{{version}}', { version: __APP_VERSION__ })}
+
+
+
diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx
index 24083c2..fe97119 100644
--- a/src/components/lists/DynamicList.tsx
+++ b/src/components/lists/DynamicList.tsx
@@ -52,6 +52,7 @@ import { toast } from '@/hooks/use-toast';
import { friendlySetError } from '@/lib/jmapErrors';
import { coerceLabel } from '@/lib/objectOptions';
import { buildJmapFilter } from '@/lib/listFilter';
+import { useResetOnChange } from '@/hooks/useBufferedValue';
import { useSchemaStore } from '@/stores/schemaStore';
import { useAuthStore } from '@/stores/authStore';
@@ -305,6 +306,24 @@ interface SortState {
ascending: boolean;
}
+function readUrlFilters(): Record {
+ const params = new URLSearchParams(window.location.search);
+ const filters: Record = {};
+ params.forEach((value, key) => {
+ if (key.startsWith('f.')) {
+ filters[key.slice(2)] = value;
+ }
+ });
+ return filters;
+}
+
+function readUrlSort(): SortState | null {
+ const params = new URLSearchParams(window.location.search);
+ const sortParam = params.get('sort');
+ const sortDir = params.get('sortDir');
+ return sortParam ? { field: sortParam, ascending: sortDir !== 'desc' } : null;
+}
+
interface ConfirmAction {
label: string;
onConfirm: () => void;
@@ -352,15 +371,15 @@ export function DynamicList({ viewName }: DynamicListProps) {
const [selectedIds, setSelectedIds] = useState>(new Set());
const [selectAllMode, setSelectAllMode] = useState(false);
- const [filtersOpen, setFiltersOpen] = useState(false);
- const [filterValues, setFilterValues] = useState>({});
- const [appliedFilters, setAppliedFilters] = useState>({});
+ const [filtersOpen, setFiltersOpen] = useState(() => Object.keys(readUrlFilters()).length > 0);
+ const [filterValues, setFilterValues] = useState>(readUrlFilters);
+ const [appliedFilters, setAppliedFilters] = useState>(readUrlFilters);
- const [sort, setSort] = useState(null);
+ const [sort, setSort] = useState(readUrlSort);
const [confirmAction, setConfirmAction] = useState(null);
- useEffect(() => {
+ useResetOnChange(viewName, () => {
setItems([]);
setTotal(null);
setAnchorStack([]);
@@ -369,30 +388,22 @@ export function DynamicList({ viewName }: DynamicListProps) {
setSelectAllMode(false);
setError(null);
- const params = new URLSearchParams(window.location.search);
- const initialFilters: Record = {};
- params.forEach((value, key) => {
- if (key.startsWith('f.')) {
- initialFilters[key.slice(2)] = value;
- }
- });
+ const initialFilters = readUrlFilters();
setFilterValues(initialFilters);
setAppliedFilters(initialFilters);
setFiltersOpen(Object.keys(initialFilters).length > 0);
+ setSort(readUrlSort());
+ });
- const sortParam = params.get('sort');
- const sortDir = params.get('sortDir');
- setSort(sortParam ? { field: sortParam, ascending: sortDir !== 'desc' } : null);
- }, [viewName]);
-
+ const objectName = resolved?.obj.objectName;
const buildFilter = useCallback((): Record => {
return buildJmapFilter({
appliedFilters,
filters: resolved?.list?.filters,
filtersStatic: resolved?.list?.filtersStatic,
- isXPrefixed: resolved?.obj.objectName.startsWith('x:') ?? false,
+ isXPrefixed: objectName?.startsWith('x:') ?? false,
});
- }, [appliedFilters, resolved?.list, resolved?.obj.objectName]);
+ }, [appliedFilters, resolved?.list, objectName]);
const buildSort = useCallback((): Record[] | undefined => {
if (!sort) return undefined;
@@ -464,6 +475,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
useEffect(() => {
if (!resolved?.list) return;
+ // eslint-disable-next-line react-hooks/set-state-in-effect
setAnchorStack([]);
setCurrentAnchor(null);
fetchData(null);
diff --git a/src/hooks/useBufferedValue.ts b/src/hooks/useBufferedValue.ts
new file mode 100644
index 0000000..ed605d2
--- /dev/null
+++ b/src/hooks/useBufferedValue.ts
@@ -0,0 +1,28 @@
+/*
+ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
+ */
+
+import { useState, type Dispatch, type SetStateAction } from 'react';
+
+export function useBufferedValue(
+ value: T,
+ transform: (v: T) => S = (v) => v as unknown as S,
+): [S, Dispatch>] {
+ const [prev, setPrev] = useState(value);
+ const [local, setLocal] = useState(() => transform(value));
+ if (!Object.is(value, prev)) {
+ setPrev(value);
+ setLocal(transform(value));
+ }
+ return [local, setLocal];
+}
+
+export function useResetOnChange(key: K, reset: () => void): void {
+ const [prev, setPrev] = useState(key);
+ if (!Object.is(key, prev)) {
+ setPrev(key);
+ reset();
+ }
+}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 6a18f5a..f35c7b8 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -294,6 +294,9 @@
"stalwartAlt": "Stalwart Logo"
},
"logout": "Logout",
+ "version": {
+ "label": "Stalwart WebUI v{{version}}"
+ },
"oauth": {
"backToLogin": "Back to login",
"discoveryFailed": "Discovery failed for \"{{username}}\": {{status}} {{statusText}}",
diff --git a/src/lib/objectOptions.ts b/src/lib/objectOptions.ts
index 75d311f..3962ba6 100644
--- a/src/lib/objectOptions.ts
+++ b/src/lib/objectOptions.ts
@@ -97,7 +97,7 @@ async function fetchObjectList(viewOrObjectName: string, schema: Schema): Promis
const accountId = getAccountId(objectName);
const displayProp = getDisplayProperty(schema, viewOrObjectName);
- let items: Array> = [];
+ let items: Array>;
try {
const result = await jmapQueryAllAndGet(
objectName,
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index 03cf709..9b7f6ad 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -18,3 +18,5 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+
+declare const __APP_VERSION__: string;
diff --git a/tsconfig.node.json b/tsconfig.node.json
index d3c52ea..5cb1aa1 100644
--- a/tsconfig.node.json
+++ b/tsconfig.node.json
@@ -10,6 +10,7 @@
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
diff --git a/vite.config.ts b/vite.config.ts
index e060ef9..89348f5 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -3,9 +3,13 @@ import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
+import { version } from './package.json'
export default defineConfig({
base: './',
+ define: {
+ __APP_VERSION__: JSON.stringify(version),
+ },
plugins: [react(), tailwindcss()],
resolve: {
alias: {