Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion assets/js/common/MultiSelect/MultiSelect.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ const defaultClassNames = {
function MultiSelect({
options,
values,
value,
isMulti = true,
disabled = false,
components = defaultComponents,
selectClassNames = defaultClassNames,
Expand All @@ -88,7 +90,8 @@ function MultiSelect({
}) {
return (
<Select
isMulti
isMulti={isMulti}
value={value}
defaultValue={values}
options={options}
classNames={selectClassNames}
Expand Down
46 changes: 46 additions & 0 deletions assets/js/common/SearchableSelect/SearchableSelect.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from 'react';
import classNames from 'classnames';
import MultiSelect from '@common/MultiSelect';

const defaultFilterOption = (option, rawInput) => {
if (!rawInput) return true;
const input = rawInput.toLowerCase();
return (
option.data.searchLabel?.toLowerCase().includes(input) ||
option.data.label.toLowerCase().includes(input)
);
};

function SearchableSelect({
value,
options,
onChange,
disabled = false,
className = '',
isClearable = false,
placeholder,
noOptionsMessage,
filterOption = defaultFilterOption,
...props
}) {
const selectedOption = options.find((opt) => opt.value === value);

return (
<MultiSelect
options={options}
value={selectedOption}
isMulti={false}
onChange={(option) => onChange(option?.value)}
disabled={disabled}
isClearable={isClearable}
isSearchable
className={classNames('text-sm', className)}
placeholder={placeholder}
noOptionsMessage={noOptionsMessage}
filterOption={filterOption}
{...props}
/>
);
}

export default SearchableSelect;
78 changes: 78 additions & 0 deletions assets/js/common/SearchableSelect/SearchableSelect.stories.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import SearchableSelect from '.';

export default {
title: 'Components/SearchableSelect',
component: SearchableSelect,
argTypes: {
options: {
type: 'array',
description: 'The list of options to render',
control: {
type: 'array',
},
},
value: {
type: 'string',
description: 'Selected option value',
control: {
type: 'text',
},
},
disabled: {
type: 'boolean',
description: 'Component is disabled or not',
control: {
type: 'boolean',
},
},
isClearable: {
type: 'boolean',
description: 'Enable clear action',
control: {
type: 'boolean',
},
},
onChange: {
description: 'A function to be called when selected option changes',
table: {
type: { summary: '(value) => {}' },
},
},
},
};

const options = [
{ value: 'Europe/Berlin', label: 'Europe/Berlin (UTC+1)', searchLabel: 'Europe/Berlin' },
{ value: 'America/New_York', label: 'America/New_York (UTC-5)', searchLabel: 'America/New_York' },
{ value: 'Asia/Tokyo', label: 'Asia/Tokyo (UTC+9)', searchLabel: 'Asia/Tokyo' },
];

export const Default = {
args: {
options,
className: 'w-96',
placeholder: 'Select timezone...',
noOptionsMessage: () => 'No options found',
},
};

export const WithValue = {
args: {
...Default.args,
value: 'Europe/Berlin',
},
};

export const Clearable = {
args: {
...WithValue.args,
isClearable: true,
},
};

export const Disabled = {
args: {
...WithValue.args,
disabled: true,
},
};
63 changes: 63 additions & 0 deletions assets/js/common/SearchableSelect/SearchableSelect.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import userEvent from '@testing-library/user-event';

import SearchableSelect from '.';

const options = [
{ value: 'Europe/Berlin', label: 'Europe/Berlin (UTC+1)', searchLabel: 'Europe/Berlin' },
{ value: 'America/New_York', label: 'America/New_York (UTC-5)', searchLabel: 'America/New_York' },
{ value: 'Asia/Tokyo', label: 'Asia/Tokyo (UTC+9)', searchLabel: 'Asia/Tokyo' },
];

describe('SearchableSelect Component', () => {
it('should render selected value', () => {
render(
<SearchableSelect
options={options}
value="Europe/Berlin"
onChange={jest.fn()}
placeholder="Select timezone..."
/>
);

expect(screen.getByText('Europe/Berlin (UTC+1)')).toBeVisible();
});

it('should call onChange with option value', async () => {
const user = userEvent.setup();
const onChange = jest.fn();

render(
<SearchableSelect
options={options}
onChange={onChange}
placeholder="Select timezone..."
/>
);

await user.click(screen.getByText('Select timezone...'));
await user.click(screen.getByText('Asia/Tokyo (UTC+9)'));

expect(onChange).toHaveBeenCalledWith('Asia/Tokyo');
});

it('should filter options by searchLabel by default', async () => {
const user = userEvent.setup();

render(
<SearchableSelect
options={options}
onChange={jest.fn()}
placeholder="Select timezone..."
/>
);

await user.click(screen.getByText('Select timezone...'));
await user.type(screen.getByRole('combobox'), 'new_york');

expect(screen.getByText('America/New_York (UTC-5)')).toBeVisible();
expect(screen.queryByText('Asia/Tokyo (UTC+9)')).not.toBeInTheDocument();
});
});
3 changes: 3 additions & 0 deletions assets/js/common/SearchableSelect/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import SearchableSelect from './SearchableSelect';

export default SearchableSelect;
2 changes: 2 additions & 0 deletions assets/js/lib/test-utils/factories/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const userFactory = Factory.define(() => ({
totp_enabled_at: formatISO(faker.date.past()),
analytics_enabled: faker.datatype.boolean(),
analytics_eula_enabled: faker.datatype.boolean(),
timezone: faker.location.timeZone(),
last_login_at: formatISO(faker.date.past()),
created_at: formatISO(faker.date.past()),
updated_at: formatISO(faker.date.past()),
Expand All @@ -35,6 +36,7 @@ export const profileFactory = Factory.define(() => ({
totp_enabled: faker.datatype.boolean(),
analytics_enabled: faker.datatype.boolean(),
analytics_eula_accepted: faker.datatype.boolean(),
timezone: faker.location.timeZone(),
created_at: formatISO(faker.date.past()),
updated_at: formatISO(faker.date.past()),
}));
Expand Down
46 changes: 46 additions & 0 deletions assets/js/lib/timezones/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import tzdata from 'tzdata';


// Default timezone is set to UTC until a user explicitly selects one.
export const DEFAULT_TIMEZONE = 'Etc/UTC';

/**
* Get the UTC offset for a timezone, accounting for DST. If invalid, returns 'UTC'.
* See https://www.iana.org/time-zones for valid timezone identifiers.
* @param {string} timezone - IANA timezone identifier
* @param {Date} [date=new Date()] - Date to calculate offset for (defaults to today)
*/
export function getUtcOffset(timezone, date = new Date()) {
try {
const formatter = new Intl.DateTimeFormat('en-GB', {
timeZone: timezone,
timeZoneName: 'shortOffset',
});
const parts = formatter.formatToParts(date);
return parts.find((p) => p.type === 'timeZoneName')?.value || 'UTC';
} catch {
return 'UTC';
}
}

/**
* Generate timezone options from the IANA tzdata database.
* Each option includes the current UTC offset with DST accounted for.
*/
export function generateTimezoneOptions() {
const zoneNames = Object.keys(tzdata.zones || {})
.filter((zone) => {
// Keep UTC/GMT but exclude other deprecated Etc/ zones
if (zone.startsWith('Etc/')) {
return ['Etc/UTC', 'Etc/GMT'].includes(zone);
}
return true;
})
.sort();

return zoneNames.map((zone) => ({
value: zone,
label: `${zone} (${getUtcOffset(zone)})`,
searchLabel: zone,
}));
}
19 changes: 19 additions & 0 deletions assets/js/lib/timezones/index.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { DEFAULT_TIMEZONE, getUtcOffset } from './index';

describe('timezones', () => {
it('uses UTC as default timezone', () => {
expect(DEFAULT_TIMEZONE).toBe('Etc/UTC');
});

it('calculates DST correctly for Europe/Berlin in winter vs summer', () => {
const winterOffset = getUtcOffset('Europe/Berlin', new Date('2024-01-01T12:00:00Z'));
const summerOffset = getUtcOffset('Europe/Berlin', new Date('2024-07-01T12:00:00Z'));

expect(winterOffset).toBe("GMT+1");
expect(summerOffset).toBe("GMT+2");
});

it('returns UTC for invalid timezone identifiers', () => {
expect(getUtcOffset('Not/A_Timezone', new Date('2024-01-01T12:00:00Z'))).toBe('UTC');
});
});
2 changes: 1 addition & 1 deletion assets/js/pages/Layout/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ function Layout() {
</div>
<footer className="p-4 z-30 relative bottom-0 w-full bg-white shadow md:flex md:items-center md:justify-between md:p-6 dark:bg-gray-800">
<span className="text-sm text-gray-500 sm:text-center dark:text-gray-400">
© 2020-2025 SUSE LLC
© 2020-2026 SUSE LLC
</span>
<span className="flex items-center mt-3 text-sm text-gray-500 dark:text-gray-400 sm:mt-0">
This tool is free software released under the Apache License,
Expand Down
30 changes: 30 additions & 0 deletions assets/js/pages/Profile/ProfileForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import Label from '@common/Label';
import Modal from '@common/Modal';
import Switch from '@common/Switch';
import AbilitiesMultiSelect from '@common/AbilitiesMultiSelect';
import SearchableSelect from '@common/SearchableSelect';
import ProfilePasswordChangeForm from '@pages/Profile/ProfilePasswordChangeForm';
import TotpEnrollementBox from '@pages/Profile/TotpEnrollmentBox';
import { DEFAULT_TIMEZONE, generateTimezoneOptions } from '@lib/timezones';

import { REQUIRED_FIELD_TEXT, errorMessage } from '@lib/forms';

Expand Down Expand Up @@ -38,6 +40,7 @@ function ProfileForm({
analyticsEnabledConfig = false,
analyticsEnabled = false,
analyticsEulaAccepted = false,
timezone = DEFAULT_TIMEZONE,
errors,
loading,
disableForm,
Expand All @@ -57,6 +60,8 @@ function ProfileForm({
const [emailAddressErrorState, setEmailAddressError] = useState(null);
const [totpDisableModalOpen, setTotpDisableModalOpen] = useState(false);
const [analyticsEnabledState, setAnalyticsState] = useState(analyticsEnabled);
const [timezoneState, setTimezone] = useState(timezone);
const [timezoneErrorState, setTimezoneError] = useState(null);

const saveButtonVisible = !singleSignOnEnabled || analyticsEnabledConfig;

Expand Down Expand Up @@ -88,6 +93,7 @@ function ProfileForm({
analytics_enabled: analyticsEnabledState,
...(analyticsEnabledState &&
!analyticsEulaAccepted && { analytics_eula_accepted: true }),
timezone: timezoneState,
};

onSave(user);
Expand All @@ -104,6 +110,7 @@ function ProfileForm({
useEffect(() => {
setFullNameError(getError('fullname', errors));
setEmailAddressError(getError('email', errors));
setTimezoneError(getError('timezone', errors));
}, [errors]);

return (
Expand Down Expand Up @@ -206,6 +213,29 @@ function ProfileForm({
disabled
/>
</div>
<Label
htmlFor="timezone"
className="col-start-1 col-span-2 pt-2"
info={"Aligns timestamps according to timezone selection"}
>
Timezone
</Label>
<div className="col-start-3 col-span-4">
<SearchableSelect
inputId="timezone"
name="timezone"
value={timezoneState}
options={generateTimezoneOptions()}
onChange={(value) => {
setTimezone(value);
setTimezoneError(null);
}}
disabled={loading || disableForm}
placeholder="Select timezone..."
noOptionsMessage={() => 'No timezones found'}
/>
{timezoneErrorState && errorMessage(timezoneErrorState)}
</div>
{analyticsEnabledConfig && (
<>
<Label
Expand Down
Loading
Loading