- {constraint.value}
+ {constraintTypeToLabel(constraint.type) ===
+ ComparisonType.DATETIME_COMPARISON_TYPE &&
+ constraint.value !== undefined
+ ? inTimezone(constraint.value)
+ : constraint.value}
|
{constraint.description}
diff --git a/ui/src/app/settings/Settings.tsx b/ui/src/app/settings/Settings.tsx
index 94712c6669..a76aa24dcb 100644
--- a/ui/src/app/settings/Settings.tsx
+++ b/ui/src/app/settings/Settings.tsx
@@ -3,6 +3,10 @@ import TabBar from '~/components/TabBar';
export default function Settings() {
const tabs = [
+ {
+ name: 'General',
+ to: '/settings'
+ },
{
name: 'Namespaces',
to: '/settings/namespaces'
diff --git a/ui/src/app/settings/general/Preferences.tsx b/ui/src/app/settings/general/Preferences.tsx
new file mode 100644
index 0000000000..6c4c2dcc63
--- /dev/null
+++ b/ui/src/app/settings/general/Preferences.tsx
@@ -0,0 +1,65 @@
+import { Switch } from '@headlessui/react';
+import { useState } from 'react';
+import { useTimezone } from '~/data/hooks/timezone';
+import { TimezoneType } from '~/types/Preferences';
+import { classNames } from '~/utils/helpers';
+
+export default function Preferences() {
+ const { timezone, setTimezone } = useTimezone();
+ const [utcTimezoneEnabled, setUtcTimezoneEnabled] = useState(
+ timezone === TimezoneType.UTC
+ );
+
+ return (
+
+
+ Preferences
+
+ Manage how information is displayed in the UI
+
+
+
+
+
+
+ UTC Timezone
+
+ Display dates and times in UTC timezone
+
+
+ -
+ {
+ setUtcTimezoneEnabled(!utcTimezoneEnabled);
+ setTimezone(
+ utcTimezoneEnabled ? TimezoneType.LOCAL : TimezoneType.UTC
+ );
+ }}
+ className={classNames(
+ utcTimezoneEnabled ? 'bg-violet-400' : 'bg-gray-200',
+ 'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none sm:ml-auto'
+ )}
+ >
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/app/settings/namespaces/Namespaces.tsx b/ui/src/app/settings/namespaces/Namespaces.tsx
index 7b129aa827..6a1dc5dbfa 100644
--- a/ui/src/app/settings/namespaces/Namespaces.tsx
+++ b/ui/src/app/settings/namespaces/Namespaces.tsx
@@ -17,7 +17,7 @@ type NamespaceContextType = {
setNamespaces: (namespaces: INamespace[]) => void;
};
-export default function Namespaces(): JSX.Element {
+export default function Namespaces() {
const { namespaces, setNamespaces } =
useOutletContext();
@@ -109,7 +109,7 @@ export default function Namespaces(): JSX.Element {
Namespaces
Namespaces allow you to group your flags, segments and rules under
- a single name.
+ a single name
diff --git a/ui/src/components/Header.tsx b/ui/src/components/Header.tsx
index 8a84d31702..e59c017bc4 100644
--- a/ui/src/components/Header.tsx
+++ b/ui/src/components/Header.tsx
@@ -3,8 +3,8 @@ import { useEffect, useState } from 'react';
import { getInfo } from '~/data/api';
import { useSession } from '~/data/hooks/session';
import { Info } from '~/types/Meta';
-import Notifications from './Notifications';
-import UserProfile from './UserProfile';
+import Notifications from './header/Notifications';
+import UserProfile from './header/UserProfile';
type HeaderProps = {
setSidebarOpen: (sidebarOpen: boolean) => void;
@@ -38,11 +38,8 @@ export default function Header(props: HeaderProps) {
-
+
{/* notifications */}
-
- {/* TODO: currently we only show the update available notification,
- this will need to be re-worked if we support other notifications */}
{info && info.updateAvailable && }
{/* user profile */}
diff --git a/ui/src/components/PreferencesProvider.tsx b/ui/src/components/PreferencesProvider.tsx
new file mode 100644
index 0000000000..8435e162b2
--- /dev/null
+++ b/ui/src/components/PreferencesProvider.tsx
@@ -0,0 +1,31 @@
+import { createContext } from 'react';
+import { useLocalStorage } from '~/data/hooks/storage';
+import { Preferences, TimezoneType } from '~/types/Preferences';
+
+interface PreferencesContextType {
+ preferences: Preferences;
+ setPreferences: (data: Preferences) => void;
+}
+
+export const PreferencesContext = createContext({} as PreferencesContextType);
+
+export default function PreferencesProvider({
+ children
+}: {
+ children: React.ReactNode;
+}) {
+ const [preferences, setPreferences] = useLocalStorage('preferences', {
+ timezone: TimezoneType.LOCAL
+ } as Preferences);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/ui/src/components/flags/FlagTable.tsx b/ui/src/components/flags/FlagTable.tsx
index 3d82237b70..096a747d7a 100644
--- a/ui/src/components/flags/FlagTable.tsx
+++ b/ui/src/components/flags/FlagTable.tsx
@@ -11,11 +11,11 @@ import {
SortingState,
useReactTable
} from '@tanstack/react-table';
-import { formatDistanceToNowStrict, parseISO } from 'date-fns';
import { useState } from 'react';
import { Link } from 'react-router-dom';
import Pagination from '~/components/Pagination';
import Searchbox from '~/components/Searchbox';
+import { useTimezone } from '~/data/hooks/timezone';
import { IFlag } from '~/types/Flag';
import { INamespace } from '~/types/Namespace';
import { truncateKey } from '~/utils/helpers';
@@ -27,6 +27,7 @@ type FlagTableProps = {
export default function FlagTable(props: FlagTableProps) {
const { namespace, flags } = props;
+ const { inTimezone } = useTimezone();
const path = `/namespaces/${namespace.key}/flags`;
@@ -87,44 +88,38 @@ export default function FlagTable(props: FlagTableProps) {
className: 'truncate whitespace-nowrap py-4 px-3 text-sm text-gray-500'
}
}),
- columnHelper.accessor(
- (row) => formatDistanceToNowStrict(parseISO(row.createdAt)),
- {
- header: 'Created',
- id: 'createdAt',
- meta: {
- className: 'whitespace-nowrap py-4 px-3 text-sm text-gray-500'
- },
- sortingFn: (
- rowA: Row ,
- rowB: Row,
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- _columnId: string
- ): number =>
- new Date(rowA.original.createdAt) < new Date(rowB.original.createdAt)
- ? 1
- : -1
- }
- ),
- columnHelper.accessor(
- (row) => formatDistanceToNowStrict(parseISO(row.updatedAt)),
- {
- header: 'Updated',
- id: 'updatedAt',
- meta: {
- className: 'whitespace-nowrap py-4 px-3 text-sm text-gray-500'
- },
- sortingFn: (
- rowA: Row,
- rowB: Row,
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- _columnId: string
- ): number =>
- new Date(rowA.original.updatedAt) < new Date(rowB.original.updatedAt)
- ? 1
- : -1
- }
- )
+ columnHelper.accessor((row) => inTimezone(row.createdAt), {
+ header: 'Created',
+ id: 'createdAt',
+ meta: {
+ className: 'whitespace-nowrap py-4 px-3 text-sm text-gray-500'
+ },
+ sortingFn: (
+ rowA: Row,
+ rowB: Row,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _columnId: string
+ ): number =>
+ new Date(rowA.original.createdAt) < new Date(rowB.original.createdAt)
+ ? 1
+ : -1
+ }),
+ columnHelper.accessor((row) => inTimezone(row.updatedAt), {
+ header: 'Updated',
+ id: 'updatedAt',
+ meta: {
+ className: 'whitespace-nowrap py-4 px-3 text-sm text-gray-500'
+ },
+ sortingFn: (
+ rowA: Row,
+ rowB: Row,
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ _columnId: string
+ ): number =>
+ new Date(rowA.original.updatedAt) < new Date(rowB.original.updatedAt)
+ ? 1
+ : -1
+ })
];
const table = useReactTable({
diff --git a/ui/src/components/Notifications.tsx b/ui/src/components/header/Notifications.tsx
similarity index 100%
rename from ui/src/components/Notifications.tsx
rename to ui/src/components/header/Notifications.tsx
diff --git a/ui/src/components/UserProfile.tsx b/ui/src/components/header/UserProfile.tsx
similarity index 100%
rename from ui/src/components/UserProfile.tsx
rename to ui/src/components/header/UserProfile.tsx
diff --git a/ui/src/components/ErrorNotification.tsx b/ui/src/components/notifications/ErrorNotification.tsx
similarity index 100%
rename from ui/src/components/ErrorNotification.tsx
rename to ui/src/components/notifications/ErrorNotification.tsx
diff --git a/ui/src/components/SuccessNotification.tsx b/ui/src/components/notifications/SuccessNotification.tsx
similarity index 100%
rename from ui/src/components/SuccessNotification.tsx
rename to ui/src/components/notifications/SuccessNotification.tsx
diff --git a/ui/src/components/segments/ConstraintForm.tsx b/ui/src/components/segments/ConstraintForm.tsx
index ff565a7b67..0cbd5643d5 100644
--- a/ui/src/components/segments/ConstraintForm.tsx
+++ b/ui/src/components/segments/ConstraintForm.tsx
@@ -1,7 +1,10 @@
import { Dialog } from '@headlessui/react';
+import { QuestionMarkCircleIcon } from '@heroicons/react/20/solid';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { Form, Formik, useField, useFormikContext } from 'formik';
+import moment from 'moment';
import { forwardRef, useEffect, useState } from 'react';
+import { Link } from 'react-router-dom';
import * as Yup from 'yup';
import Button from '~/components/forms/Button';
import Input from '~/components/forms/Input';
@@ -12,16 +15,19 @@ import { createConstraint, updateConstraint } from '~/data/api';
import { useError } from '~/data/hooks/error';
import useNamespace from '~/data/hooks/namespace';
import { useSuccess } from '~/data/hooks/success';
+import { useTimezone } from '~/data/hooks/timezone';
import { requiredValidation } from '~/data/validations';
import {
ComparisonType,
ConstraintBooleanOperators,
+ ConstraintDateTimeOperators,
ConstraintNumberOperators,
ConstraintStringOperators,
IConstraint,
IConstraintBase,
NoValueOperators
} from '~/types/Constraint';
+import { TimezoneType } from '~/types/Preferences';
const constraintComparisonTypes = () =>
(Object.keys(ComparisonType) as Array).map(
@@ -43,6 +49,9 @@ const constraintOperators = (c: string) => {
case ComparisonType.BOOLEAN_COMPARISON_TYPE:
opts = ConstraintBooleanOperators;
break;
+ case ComparisonType.DATETIME_COMPARISON_TYPE:
+ opts = ConstraintDateTimeOperators;
+ break;
}
return Object.entries(opts).map(([k, v]) => ({
value: k,
@@ -50,16 +59,20 @@ const constraintOperators = (c: string) => {
}));
};
-type InputProps = {
+type ConstraintInputProps = {
name: string;
id: string;
};
-function ConstraintOperatorSelect(props: InputProps) {
- const {
- values: { type },
- setFieldValue
- } = useFormikContext<{ type: string }>();
+type ConstraintOperatorSelectProps = ConstraintInputProps & {
+ onChange: (e: React.ChangeEvent) => void;
+ type: string;
+};
+
+function ConstraintOperatorSelect(props: ConstraintOperatorSelectProps) {
+ const { onChange, type } = props;
+
+ const { setFieldValue } = useFormikContext();
const [field] = useField(props);
@@ -70,44 +83,81 @@ function ConstraintOperatorSelect(props: InputProps) {
{...props}
handleChange={(e) => {
setFieldValue(field.name, e.target.value);
+ onChange(e);
}}
options={constraintOperators(type)}
/>
);
}
-function ConstraintValueField(props: InputProps) {
- const [show, setShow] = useState(true);
- const {
- values: { type, operator },
- dirty
- } = useFormikContext<{ type: string; operator: string }>();
-
+function ConstraintValueInput(props: ConstraintInputProps) {
const [field] = useField({
...props,
validate: (value) => {
- if (!show) {
- return undefined;
- }
-
// value is required only if shown
return value ? undefined : 'Value is required';
}
});
- // show/hide value field based on operator
+ return (
+
+ );
+}
+
+function ConstraintValueDateTimeInput(props: ConstraintInputProps) {
+ const { setFieldValue } = useFormikContext();
+ const { timezone } = useTimezone();
+
+ const [field] = useField({
+ ...props,
+ validate: (value) => {
+ const m = moment(value);
+ return m.isValid() ? undefined : 'Value is not a valid datetime';
+ }
+ });
+
+ const [fieldDate, setFieldDate] = useState(field.value?.split('T')[0] || '');
+ const [fieldTime, setFieldTime] = useState(field.value?.split('T')[1] || '');
+
useEffect(() => {
- if (type === 'BOOLEAN_COMPARISON_TYPE') {
- setShow(false);
+ // if both date and time are set, then combine, parse, and set the value
+ if (
+ fieldDate &&
+ fieldDate.trim() !== '' &&
+ fieldTime &&
+ fieldTime.trim() !== ''
+ ) {
+ if (timezone === TimezoneType.LOCAL) {
+ // if local timezone, then parse as local (moment default) and convert to UTC
+ const m = moment(`${fieldDate}T${fieldTime}`);
+ setFieldValue(field.name, m.utc().format());
+ return;
+ }
+
+ // otherwise, parse as UTC
+ const m = moment.utc(`${fieldDate}T${fieldTime}`);
+ setFieldValue(field.name, m.format());
return;
}
- const noValue = NoValueOperators.includes(operator);
- setShow(!noValue);
- }, [type, operator, field.name, dirty]);
- if (!show) {
- return <>>;
- }
+ // otherwise, if only date is set, then parse and set the value
+ if (fieldDate && fieldDate.trim() !== '') {
+ const m = moment(fieldDate);
+ setFieldValue(field.name, m.utc().format());
+ }
+ }, [field.name, fieldDate, fieldTime, setFieldValue]);
return (
@@ -118,10 +168,46 @@ function ConstraintValueField(props: InputProps) {
>
Value
+
+
+
+ {timezone}
+
+
-
);
}
@@ -143,9 +229,14 @@ const ConstraintForm = forwardRef((props: ConstraintFormProps, ref: any) => {
const { setError, clearError } = useError();
const { setSuccess } = useSuccess();
+ const [hasValue, setHasValue] = useState(true);
+ const [type, setType] = useState(
+ constraint?.type || 'STRING_COMPARISON_TYPE'
+ );
+
const { currentNamespace } = useNamespace();
- const initialValues: IConstraintBase = {
+ const initialValues = {
property: constraint?.property || '',
type: constraint?.type || ('STRING_COMPARISON_TYPE' as ComparisonType),
operator: constraint?.operator || 'eq',
@@ -247,12 +338,16 @@ const ConstraintForm = forwardRef((props: ConstraintFormProps, ref: any) => {
value={formik.values.type}
options={constraintComparisonTypes()}
handleChange={(e) => {
- formik.setFieldValue('type', e.target.value);
+ const type = e.target.value as ComparisonType;
+ formik.setFieldValue('type', type);
+ setType(type);
if (e.target.value === 'BOOLEAN_COMPARISON_TYPE') {
formik.setFieldValue('operator', 'true');
+ setHasValue(false);
} else {
formik.setFieldValue('operator', 'eq');
+ setHasValue(true);
}
}}
/>
@@ -268,10 +363,23 @@ const ConstraintForm = forwardRef((props: ConstraintFormProps, ref: any) => {
-
+ {
+ const noValue = NoValueOperators.includes(e.target.value);
+ setHasValue(!noValue);
+ }}
+ />
-
+ {hasValue && type != 'DATETIME_COMPARISON_TYPE' && (
+
+ )}
+ {hasValue && type === 'DATETIME_COMPARISON_TYPE' && (
+
+ )}
|