Skip to content

Commit b3614ba

Browse files
committed
Add initial structure for create org screen
1 parent 96591f9 commit b3614ba

File tree

4 files changed

+230
-11
lines changed

4 files changed

+230
-11
lines changed

packages/clerk-js/src/ui/components/OrganizationList/OrganizationListPage.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ import { MembershipPreview, PersonalAccountPreview } from './UserMembershipList'
1717
import { SuggestionPreview } from './UserSuggestionList';
1818
import { organizationListParams } from './utils';
1919

20-
const useOrganizationListInView = () => {
20+
/**
21+
* @internal
22+
*/
23+
export const useOrganizationListInView = () => {
2124
const { userMemberships, userInvitations, userSuggestions } = useOrganizationList(organizationListParams);
2225

2326
const { ref } = useInView({
Lines changed: 218 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,47 @@
1-
import { useUser } from '@clerk/shared/react';
1+
import { useClerk, useOrganizationList, useUser } from '@clerk/shared/react';
2+
import type { CreateOrganizationParams } from '@clerk/types';
3+
import React, { useState } from 'react';
24

35
import { withCoreSessionSwitchGuard } from '@/ui/contexts';
4-
import { Flow, localizationKeys } from '@/ui/customizables';
6+
import { useSessionTasksContext } from '@/ui/contexts/components/SessionTasks';
7+
import { Col, descriptors, Flex, Flow, Icon, localizationKeys, Spinner } from '@/ui/customizables';
58
import { Card } from '@/ui/elements/Card';
6-
import { withCardStateProvider } from '@/ui/elements/contexts';
7-
import { FullHeightLoader } from '@/ui/elements/FullHeightLoader';
9+
import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
10+
import { Form } from '@/ui/elements/Form';
11+
import { FormButtonContainer } from '@/ui/elements/FormButtons';
12+
import { FormContainer } from '@/ui/elements/FormContainer';
813
import { Header } from '@/ui/elements/Header';
14+
import { IconButton } from '@/ui/elements/IconButton';
15+
import { Organization } from '@/ui/icons';
16+
import { createSlug } from '@/ui/utils/createSlug';
17+
import { handleError } from '@/ui/utils/errorHandler';
18+
import { useFormControl } from '@/ui/utils/useFormControl';
919
import { getIdentifier } from '@/utils/user';
1020

21+
import { useOrganizationListInView } from '../../OrganizationList/OrganizationListPage';
22+
import { OrganizationProfileAvatarUploader } from '../../OrganizationProfile/OrganizationProfileAvatarUploader';
23+
import { organizationListParams } from '../../OrganizationSwitcher/utils';
1124
import { withTaskGuard } from './withTaskGuard';
1225

1326
const TaskSelectOrganizationInternal = () => {
1427
const { user } = useUser();
28+
const { userMemberships, userSuggestions, userInvitations } = useOrganizationListInView();
1529

16-
// TODO -> Improve loading UI to keep consistent height with SignIn/SignUp
17-
const isLoading = false;
30+
const isLoading = userMemberships?.isLoading || userInvitations?.isLoading || userSuggestions?.isLoading;
31+
const hasExistingResources = !!(userMemberships?.count || userInvitations?.count || userSuggestions?.count);
1832

1933
return (
2034
<Flow.Root flow='taskSelectOrganization'>
2135
<Card.Root>
22-
{isLoading ? (
23-
<FullHeightLoader />
24-
) : (
36+
{!isLoading && user ? (
2537
<>
2638
<Card.Content>
2739
<Header.Root showLogo>
2840
<Header.Title localizationKey={localizationKeys('taskSelectOrganization.title')} />
2941
<Header.Subtitle localizationKey={localizationKeys('taskSelectOrganization.subtitle')} />
3042
</Header.Root>
43+
44+
<TaskSelectOrganizationFlows initialFlow={hasExistingResources ? 'create' : 'select'} />
3145
</Card.Content>
3246
<Card.Footer>
3347
<Card.Action elementId='signOut'>
@@ -36,19 +50,213 @@ const TaskSelectOrganizationInternal = () => {
3650
// TODO -> Change this key name to identifier
3751
// TODO -> what happens if the user does not email address? only username or phonenumber
3852
// Signed in as +55482323232
39-
emailAddress: user.primaryEmailAddress.emailAddress || getIdentifier(user),
53+
emailAddress: user.primaryEmailAddress?.emailAddress || getIdentifier(user),
4054
})}
4155
/>
4256
<Card.ActionLink localizationKey={localizationKeys('taskSelectOrganization.signOut.actionLink')} />
4357
</Card.Action>
4458
</Card.Footer>
4559
</>
60+
) : (
61+
// TODO -> Improve loading UI to keep consistent height with SignIn/SignUp
62+
<Flex
63+
direction={'row'}
64+
align={'center'}
65+
justify={'center'}
66+
sx={t => ({
67+
height: '100%',
68+
minHeight: t.sizes.$100,
69+
})}
70+
>
71+
<Spinner
72+
size={'lg'}
73+
colorScheme={'primary'}
74+
elementDescriptor={descriptors.spinner}
75+
/>
76+
</Flex>
4677
)}
4778
</Card.Root>
4879
</Flow.Root>
4980
);
5081
};
5182

83+
type TaskSelectOrganizationFlowsProps = {
84+
initialFlow: 'create' | 'select';
85+
};
86+
87+
const TaskSelectOrganizationFlows = withCardStateProvider((props: TaskSelectOrganizationFlowsProps) => {
88+
const [currentFlow, setCurrentFlow] = useState(props.initialFlow);
89+
90+
if (currentFlow === 'create') {
91+
return (
92+
<CreateOrganizationScreen
93+
onCancel={props.initialFlow === 'select' ? () => setCurrentFlow('select') : undefined}
94+
/>
95+
);
96+
}
97+
98+
return <></>;
99+
});
100+
101+
type CreateOrganizationScreenProps = {
102+
onCancel?: () => void;
103+
hideSlug?: boolean;
104+
};
105+
106+
const CreateOrganizationScreen = withCardStateProvider((props: CreateOrganizationScreenProps) => {
107+
const card = useCardState();
108+
const { __internal_navigateToTaskIfAvailable } = useClerk();
109+
const { redirectUrlComplete } = useSessionTasksContext();
110+
const { createOrganization, isLoaded, setActive } = useOrganizationList({
111+
userMemberships: organizationListParams.userMemberships,
112+
});
113+
114+
const [file, setFile] = useState<File | null>();
115+
const nameField = useFormControl('name', '', {
116+
type: 'text',
117+
label: localizationKeys('formFieldLabel__organizationName'),
118+
placeholder: localizationKeys('formFieldInputPlaceholder__organizationName'),
119+
});
120+
const slugField = useFormControl('slug', '', {
121+
type: 'text',
122+
label: localizationKeys('formFieldLabel__organizationSlug'),
123+
placeholder: localizationKeys('formFieldInputPlaceholder__organizationSlug'),
124+
});
125+
126+
const onSubmit = async (e: React.FormEvent) => {
127+
e.preventDefault();
128+
129+
if (!isLoaded) {
130+
return;
131+
}
132+
133+
try {
134+
const createOrgParams: CreateOrganizationParams = { name: nameField.value };
135+
136+
if (!props.hideSlug) {
137+
// TODO -> Should we always show the slug
138+
// should it be exposed as a prop on TaskSelectOrganization
139+
createOrgParams.slug = slugField.value;
140+
}
141+
142+
const organization = await createOrganization(createOrgParams);
143+
144+
if (file) {
145+
await organization.setLogo({ file });
146+
}
147+
148+
await setActive({ organization });
149+
150+
await __internal_navigateToTaskIfAvailable({ redirectUrlComplete });
151+
} catch (err) {
152+
handleError(err, [nameField, slugField], card.setError);
153+
}
154+
};
155+
156+
const onAvatarRemove = () => {
157+
card.setIdle();
158+
return setFile(null);
159+
};
160+
161+
const onChangeName = (event: React.ChangeEvent<HTMLInputElement>) => {
162+
nameField.setValue(event.target.value);
163+
updateSlugField(createSlug(event.target.value));
164+
};
165+
166+
const onChangeSlug = (event: React.ChangeEvent<HTMLInputElement>) => {
167+
updateSlugField(event.target.value);
168+
};
169+
170+
const updateSlugField = (val: string) => {
171+
slugField.setValue(val);
172+
};
173+
174+
const isSubmitButtonDisabled = !nameField.value || !isLoaded;
175+
176+
return (
177+
<FormContainer sx={t => ({ minHeight: t.sizes.$60, gap: t.space.$6, textAlign: 'left' })}>
178+
<Form.Root onSubmit={onSubmit}>
179+
<Col>
180+
<OrganizationProfileAvatarUploader
181+
organization={{ name: nameField.value }}
182+
// TODO - Fix type of `onAvatarChange`
183+
onAvatarChange={async file => setFile(file)}
184+
onAvatarRemove={file ? onAvatarRemove : null}
185+
avatarPreviewPlaceholder={
186+
<IconButton
187+
variant='ghost'
188+
aria-label='Upload organization logo'
189+
// TODO -> Update to icon from Figma
190+
icon={
191+
<Icon
192+
size='md'
193+
icon={Organization}
194+
sx={t => ({
195+
color: t.colors.$colorMutedForeground,
196+
transitionDuration: t.transitionDuration.$controls,
197+
})}
198+
/>
199+
}
200+
sx={t => ({
201+
width: t.sizes.$16,
202+
height: t.sizes.$16,
203+
borderRadius: t.radii.$md,
204+
borderWidth: t.borderWidths.$normal,
205+
borderStyle: t.borderStyles.$dashed,
206+
borderColor: t.colors.$borderAlpha200,
207+
backgroundColor: t.colors.$neutralAlpha50,
208+
':hover': {
209+
backgroundColor: t.colors.$neutralAlpha50,
210+
svg: {
211+
transform: 'scale(1.2)',
212+
},
213+
},
214+
})}
215+
/>
216+
}
217+
/>
218+
</Col>
219+
<Form.ControlRow elementId={nameField.id}>
220+
<Form.PlainInput
221+
{...nameField.props}
222+
onChange={onChangeName}
223+
isRequired
224+
// TODO -> Remove auto focus?
225+
autoFocus
226+
ignorePasswordManager
227+
/>
228+
</Form.ControlRow>
229+
{!props.hideSlug && (
230+
<Form.ControlRow elementId={slugField.id}>
231+
<Form.PlainInput
232+
{...slugField.props}
233+
onChange={onChangeSlug}
234+
isRequired
235+
pattern='^(?=.*[a-z0-9])[a-z0-9\-]+$'
236+
ignorePasswordManager
237+
/>
238+
</Form.ControlRow>
239+
)}
240+
<FormButtonContainer sx={t => ({ marginTop: t.space.$none, flexDirection: 'column' })}>
241+
<Form.SubmitButton
242+
block={false}
243+
sx={() => ({ width: '100%' })}
244+
isDisabled={isSubmitButtonDisabled}
245+
localizationKey={localizationKeys('taskSelectOrganization.createOrganizationScreen.formButtonSubmit')}
246+
/>
247+
{props.onCancel && (
248+
<Form.ResetButton
249+
localizationKey={localizationKeys('taskSelectOrganization.createOrganizationScreen.formButtonReset')}
250+
block={false}
251+
onClick={props.onCancel}
252+
/>
253+
)}
254+
</FormButtonContainer>
255+
</Form.Root>
256+
</FormContainer>
257+
);
258+
});
259+
52260
export const TaskSelectOrganization = withCoreSessionSwitchGuard(
53261
withTaskGuard(withCardStateProvider(TaskSelectOrganizationInternal)),
54262
);

packages/localizations/src/en-US.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export const enUS: LocalizationResource = {
99
actionLink: 'Sign out',
1010
actionText: 'Signed in as {{emailAddress}}',
1111
},
12+
createOrganizationScreen: {
13+
formButtonSubmit: 'Create organization',
14+
formButtonReset: 'Cancel',
15+
},
1216
},
1317
apiKeys: {
1418
action__add: 'Add new key',

packages/types/src/localization.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1209,6 +1209,10 @@ export type __internal_LocalizationResource = {
12091209
actionText: LocalizationValue<'emailAddress'>;
12101210
actionLink: LocalizationValue;
12111211
};
1212+
createOrganizationScreen: {
1213+
formButtonSubmit: LocalizationValue;
1214+
formButtonReset: LocalizationValue;
1215+
};
12121216
};
12131217
};
12141218

0 commit comments

Comments
 (0)