feat: add timezone selection to workspace settings (#8248)

* feat: add timezone selection to workspace onboarding, creation and settings

* refactor: remove timezone selection from workspace creation and onboarding forms
This commit is contained in:
b-saikrishnakanth 2025-12-10 00:59:39 +05:30 committed by GitHub
parent 0bfb74d4c0
commit 079a624006
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 187 additions and 131 deletions

View file

@ -18,7 +18,7 @@ import { AuthenticationWrapper } from "@/lib/wrappers/authentication-wrapper";
// plane web helpers
import { getIsWorkspaceCreationDisabled } from "@/plane-web/helpers/instance.helper";
function CreateWorkspacePage() {
const CreateWorkspacePage = observer(function CreateWorkspacePage() {
const { t } = useTranslation();
// router
const router = useAppRouter();
@ -104,6 +104,6 @@ function CreateWorkspacePage() {
</div>
</AuthenticationWrapper>
);
}
});
export default observer(CreateWorkspacePage);
export default CreateWorkspacePage;

View file

@ -1,4 +1,4 @@
import React, { useState } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { CircleCheck } from "lucide-react";
@ -71,47 +71,46 @@ export const WorkspaceCreateStep = observer(function WorkspaceCreateStep({
const handleCreateWorkspace = async (formData: IWorkspace) => {
if (isSubmitting) return;
await workspaceService
.workspaceSlugCheck(formData.slug)
.then(async (res) => {
if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) {
setSlugError(false);
await createWorkspace(formData)
.then(async (workspaceResponse) => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("workspace_creation.toast.success.title"),
message: t("workspace_creation.toast.success.message"),
});
captureSuccess({
eventName: WORKSPACE_TRACKER_EVENTS.create,
payload: { slug: formData.slug },
});
await fetchWorkspaces();
await completeStep(workspaceResponse.id);
onComplete(formData.organization_size === "Just myself");
})
.catch(() => {
captureError({
eventName: WORKSPACE_TRACKER_EVENTS.create,
payload: { slug: formData.slug },
error: new Error("Error creating workspace"),
});
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_creation.toast.error.title"),
message: t("workspace_creation.toast.error.message"),
});
});
} else setSlugError(true);
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_creation.toast.error.title"),
message: t("workspace_creation.toast.error.message"),
})
);
try {
const res = (await workspaceService.workspaceSlugCheck(formData.slug)) as { status: boolean };
if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) {
setSlugError(false);
try {
const workspaceResponse = await createWorkspace(formData);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("workspace_creation.toast.success.title"),
message: t("workspace_creation.toast.success.message"),
});
captureSuccess({
eventName: WORKSPACE_TRACKER_EVENTS.create,
payload: { slug: formData.slug },
});
await fetchWorkspaces();
await completeStep(workspaceResponse.id);
onComplete(formData.organization_size === "Just myself");
} catch {
captureError({
eventName: WORKSPACE_TRACKER_EVENTS.create,
payload: { slug: formData.slug },
error: new Error("Error creating workspace"),
});
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_creation.toast.error.title"),
message: t("workspace_creation.toast.error.message"),
});
}
} else {
setSlugError(true);
}
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_creation.toast.error.title"),
message: t("workspace_creation.toast.error.message"),
});
}
};
const completeStep = async (workspaceId: string) => {
@ -136,7 +135,12 @@ export const WorkspaceCreateStep = observer(function WorkspaceCreateStep({
);
}
return (
<form className="flex flex-col gap-10" onSubmit={handleSubmit(handleCreateWorkspace)}>
<form
className="flex flex-col gap-10"
onSubmit={(e) => {
void handleSubmit(handleCreateWorkspace)(e);
}}
>
<CommonOnboardingHeader title="Create your workspace" description="All your work — unified." />
<div className="flex flex-col gap-8">
<div className="flex flex-col gap-2">
@ -181,6 +185,7 @@ export const WorkspaceCreateStep = observer(function WorkspaceCreateStep({
"border-red-500": errors.name,
}
)}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
</div>

View file

@ -67,47 +67,46 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
} = useForm<IWorkspace>({ defaultValues, mode: "onChange" });
const handleCreateWorkspace = async (formData: IWorkspace) => {
await workspaceService
.workspaceSlugCheck(formData.slug)
.then(async (res) => {
if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) {
setSlugError(false);
try {
const res = (await workspaceService.workspaceSlugCheck(formData.slug)) as { status: boolean };
if (res.status === true && !RESTRICTED_URLS.includes(formData.slug)) {
setSlugError(false);
await createWorkspace(formData)
.then(async (res) => {
captureSuccess({
eventName: WORKSPACE_TRACKER_EVENTS.create,
payload: { slug: formData.slug },
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("workspace_creation.toast.success.title"),
message: t("workspace_creation.toast.success.message"),
});
try {
const workspaceResponse = await createWorkspace(formData);
captureSuccess({
eventName: WORKSPACE_TRACKER_EVENTS.create,
payload: { slug: formData.slug },
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("workspace_creation.toast.success.title"),
message: t("workspace_creation.toast.success.message"),
});
if (onSubmit) await onSubmit(res);
})
.catch(() => {
captureError({
eventName: WORKSPACE_TRACKER_EVENTS.create,
payload: { slug: formData.slug },
error: new Error("Error creating workspace"),
});
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_creation.toast.error.title"),
message: t("workspace_creation.toast.error.message"),
});
});
} else setSlugError(true);
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_creation.toast.error.title"),
message: t("workspace_creation.toast.error.message"),
});
if (onSubmit) await onSubmit(workspaceResponse);
} catch {
captureError({
eventName: WORKSPACE_TRACKER_EVENTS.create,
payload: { slug: formData.slug },
error: new Error("Error creating workspace"),
});
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_creation.toast.error.title"),
message: t("workspace_creation.toast.error.message"),
});
}
} else {
setSlugError(true);
}
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: t("workspace_creation.toast.error.title"),
message: t("workspace_creation.toast.error.message"),
});
}
};
useEffect(
@ -119,7 +118,12 @@ export const CreateWorkspaceForm = observer(function CreateWorkspaceForm(props:
);
return (
<form className="space-y-6 sm:space-y-9" onSubmit={handleSubmit(handleCreateWorkspace)}>
<form
className="space-y-6 sm:space-y-9"
onSubmit={(e) => {
void handleSubmit(handleCreateWorkspace)(e);
}}
>
<div className="space-y-6 sm:space-y-7">
<div className="space-y-1 text-sm">
<label htmlFor="workspaceName">

View file

@ -19,6 +19,7 @@ import { copyUrlToClipboard, getFileURL } from "@plane/utils";
// components
import { WorkspaceImageUploadModal } from "@/components/core/modals/workspace-image-upload-modal";
// helpers
import { TimezoneSelect } from "@/components/global/timezone-select";
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
@ -30,6 +31,7 @@ const defaultValues: Partial<IWorkspace> = {
url: "",
organization_size: "2-10",
logo_url: null,
timezone: "UTC",
};
export const WorkspaceDetails = observer(function WorkspaceDetails() {
@ -62,64 +64,69 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {
const payload: Partial<IWorkspace> = {
name: formData.name,
organization_size: formData.organization_size,
timezone: formData.timezone,
};
await updateWorkspace(currentWorkspace.slug, payload)
.then(() => {
captureSuccess({
eventName: WORKSPACE_TRACKER_EVENTS.update,
payload: { slug: currentWorkspace.slug },
});
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Workspace updated successfully",
});
})
.catch((err) => {
captureError({
eventName: WORKSPACE_TRACKER_EVENTS.update,
payload: { slug: currentWorkspace.slug },
error: err,
});
console.error(err);
try {
await updateWorkspace(currentWorkspace.slug, payload);
captureSuccess({
eventName: WORKSPACE_TRACKER_EVENTS.update,
payload: { slug: currentWorkspace.slug },
});
setTimeout(() => {
setIsLoading(false);
}, 300);
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: "Workspace updated successfully",
});
} catch (err: unknown) {
captureError({
eventName: WORKSPACE_TRACKER_EVENTS.update,
payload: { slug: currentWorkspace.slug },
error: err instanceof Error ? err : new Error(String(err)),
});
console.error(err);
} finally {
setTimeout(() => {
setIsLoading(false);
}, 300);
}
};
const handleRemoveLogo = async () => {
if (!currentWorkspace) return;
await updateWorkspace(currentWorkspace.slug, {
logo_url: "",
})
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Workspace picture removed successfully.",
});
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "There was some error in deleting your profile picture. Please try again.",
});
try {
await updateWorkspace(currentWorkspace.slug, {
logo_url: "",
});
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Workspace picture removed successfully.",
});
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "There was some error in deleting your profile picture. Please try again.",
});
}
};
const handleCopyUrl = () => {
if (!currentWorkspace) return;
copyUrlToClipboard(`${currentWorkspace.slug}`).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Workspace URL copied to the clipboard.",
void copyUrlToClipboard(`${currentWorkspace.slug}`)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Workspace URL copied to the clipboard.",
});
return undefined;
})
.catch(() => {
// Silently handle clipboard errors
});
});
};
useEffect(() => {
@ -264,12 +271,30 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {
onChange={onChange}
ref={ref}
hasError={Boolean(errors.url)}
className="w-full"
className="w-full cursor-not-allowed rounded-md !bg-custom-background-90"
disabled
/>
)}
/>
</div>
<div className="flex flex-col gap-1 ">
<h4 className="text-sm">{t("workspace_settings.settings.general.workspace_timezone")}</h4>
<Controller
name="timezone"
control={control}
render={({ field: { value, onChange } }) => (
<>
<TimezoneSelect
value={value}
onChange={onChange}
buttonClassName="border-none"
disabled={!isAdmin}
/>
</>
)}
/>
</div>
</div>
{isAdmin && (
@ -277,7 +302,9 @@ export const WorkspaceDetails = observer(function WorkspaceDetails() {
<Button
data-ph-element={WORKSPACE_TRACKER_ELEMENTS.UPDATE_WORKSPACE_BUTTON}
variant="primary"
onClick={handleSubmit(onSubmit)}
onClick={(e) => {
void handleSubmit(onSubmit)(e);
}}
loading={isLoading}
>
{isLoading ? t("updating") : t("workspace_settings.settings.general.update_workspace")}

View file

@ -1578,6 +1578,7 @@ export default {
name: "Název pracovního prostoru",
company_size: "Velikost společnosti",
url: "URL pracovního prostoru",
workspace_timezone: "Časové pásmo pracovního prostoru",
update_workspace: "Aktualizovat prostor",
delete_workspace: "Smazat tento prostor",
delete_workspace_description: "Smazáním prostoru odstraníte všechna data a zdroje. Akce je nevratná.",

View file

@ -1596,6 +1596,7 @@ export default {
name: "Name des Arbeitsbereichs",
company_size: "Unternehmensgröße",
url: "URL des Arbeitsbereichs",
workspace_timezone: "Zeitzone des Arbeitsbereichs",
update_workspace: "Arbeitsbereich aktualisieren",
delete_workspace: "Diesen Arbeitsbereich löschen",
delete_workspace_description:

View file

@ -1431,6 +1431,7 @@ export default {
name: "Workspace name",
company_size: "Company size",
url: "Workspace URL",
workspace_timezone: "Workspace Timezone",
update_workspace: "Update workspace",
delete_workspace: "Delete this workspace",
delete_workspace_description:

View file

@ -1600,6 +1600,7 @@ export default {
name: "Nombre del espacio de trabajo",
company_size: "Tamaño de la empresa",
url: "URL del espacio de trabajo",
workspace_timezone: "Zona horaria del espacio de trabajo",
update_workspace: "Actualizar espacio de trabajo",
delete_workspace: "Eliminar este espacio de trabajo",
delete_workspace_description:

View file

@ -1598,6 +1598,7 @@ export default {
name: "Nom de lespace de travail",
company_size: "Taille de lentreprise",
url: "URL de lespace de travail",
workspace_timezone: "Fuseau horaire de lespace de travail",
update_workspace: "Mettre à jour lespace de travail",
delete_workspace: "Supprimer cet espace de travail",
delete_workspace_description:

View file

@ -1586,6 +1586,7 @@ export default {
name: "Nama ruang kerja",
company_size: "Ukuran perusahaan",
url: "URL ruang kerja",
workspace_timezone: "Zona waktu ruang kerja",
update_workspace: "Perbarui ruang kerja",
delete_workspace: "Hapus ruang kerja ini",
delete_workspace_description:

View file

@ -1590,6 +1590,7 @@ export default {
name: "Nome dello spazio di lavoro",
company_size: "Dimensione aziendale",
url: "URL dello spazio di lavoro",
workspace_timezone: "Fuso orario dello spazio di lavoro",
update_workspace: "Aggiorna spazio di lavoro",
delete_workspace: "Elimina questo spazio di lavoro",
delete_workspace_description:

View file

@ -1577,6 +1577,7 @@ export default {
name: "ワークスペース名",
company_size: "会社の規模",
url: "ワークスペースURL",
workspace_timezone: "ワークスペースのタイムゾーン",
update_workspace: "ワークスペースを更新",
delete_workspace: "このワークスペースを削除",
delete_workspace_description:

View file

@ -1570,6 +1570,7 @@ export default {
name: "작업 공간 이름",
company_size: "회사 규모",
url: "작업 공간 URL",
workspace_timezone: "작업 공간 시간대",
update_workspace: "작업 공간 업데이트",
delete_workspace: "이 작업 공간 삭제",
delete_workspace_description:

View file

@ -1581,6 +1581,7 @@ export default {
name: "Nazwa przestrzeni roboczej",
company_size: "Rozmiar firmy",
url: "URL przestrzeni roboczej",
workspace_timezone: "Strefa czasowa przestrzeni roboczej",
update_workspace: "Zaktualizuj przestrzeń",
delete_workspace: "Usuń tę przestrzeń",
delete_workspace_description:

View file

@ -1598,6 +1598,7 @@ export default {
name: "Nome do espaço de trabalho",
company_size: "Tamanho da empresa",
url: "URL do espaço de trabalho",
workspace_timezone: "Fuso horário do espaço de trabalho",
update_workspace: "Atualizar espaço de trabalho",
delete_workspace: "Excluir este espaço de trabalho",
delete_workspace_description:

View file

@ -1590,6 +1590,7 @@ export default {
name: "Numele spațiului de lucru",
company_size: "Dimensiunea companiei",
url: "URL-ul spațiului de lucru",
workspace_timezone: "Fusul orar al spațiului de lucru",
update_workspace: "Actualizează spațiul de lucru",
delete_workspace: "Șterge acest spațiu de lucru",
delete_workspace_description:

View file

@ -1583,6 +1583,7 @@ export default {
name: "Название пространства",
company_size: "Размер компании",
url: "URL пространства",
workspace_timezone: "Часовой пояс рабочего пространства",
update_workspace: "Обновить пространство",
delete_workspace: "Удалить пространство",
delete_workspace_description: "Все данные будут безвозвратно удалены.",

View file

@ -1581,6 +1581,7 @@ export default {
name: "Názov pracovného priestoru",
company_size: "Veľkosť spoločnosti",
url: "URL pracovného priestoru",
workspace_timezone: "Časové pásmo pracovného priestoru",
update_workspace: "Aktualizovať priestor",
delete_workspace: "Zmazať tento priestor",
delete_workspace_description: "Zmazaním priestoru odstránite všetky dáta a zdroje. Akcia je nevratná.",

View file

@ -1586,6 +1586,7 @@ export default {
name: "Çalışma Alanı Adı",
company_size: "Şirket Büyüklüğü",
url: "Çalışma Alanı URL'si",
workspace_timezone: "Çalışma Alanı Saat Dilimi",
update_workspace: "Çalışma Alanını Güncelle",
delete_workspace: "Bu çalışma alanını sil",
delete_workspace_description:

View file

@ -1585,6 +1585,7 @@ export default {
name: "Назва робочого простору",
company_size: "Розмір компанії",
url: "URL робочого простору",
workspace_timezone: "Часовий пояс робочого простору",
update_workspace: "Оновити простір",
delete_workspace: "Видалити цей простір",
delete_workspace_description: "Видалення простору призведе до втрати всіх даних і ресурсів. Дія незворотна.",

View file

@ -1587,6 +1587,7 @@ export default {
name: "Tên không gian làm việc",
company_size: "Quy mô công ty",
url: "URL không gian làm việc",
workspace_timezone: "Múi giờ không gian làm việc",
update_workspace: "Cập nhật không gian làm việc",
delete_workspace: "Xóa không gian làm việc này",
delete_workspace_description:

View file

@ -1560,6 +1560,7 @@ export default {
name: "工作区名称",
company_size: "公司规模",
url: "工作区网址",
workspace_timezone: "工作区时区",
update_workspace: "更新工作区",
delete_workspace: "删除此工作区",
delete_workspace_description: "删除工作区时,该工作区内的所有数据和资源将被永久删除,且无法恢复。",

View file

@ -1561,6 +1561,7 @@ export default {
name: "工作區名稱",
company_size: "公司規模",
url: "工作區網址",
workspace_timezone: "工作區時區",
update_workspace: "更新工作區",
delete_workspace: "刪除此工作區",
delete_workspace_description: "刪除工作區時,該工作區內的所有資料和資源都將被永久移除且無法復原。",

View file

@ -27,6 +27,7 @@ export interface IWorkspace {
organization_size: string;
total_projects?: number;
role: number;
timezone: string;
}
export interface IWorkspaceLite {
@ -228,7 +229,7 @@ export interface IWorkspaceProgressResponse {
unstarted_issues: number;
}
export interface IWorkspaceAnalyticsResponse {
completion_chart: any;
completion_chart: Record<string, unknown>;
}
export type TWorkspacePaginationInfo = TPaginationInfo & {