Skip to content

Commit a5af7fc

Browse files
committed
Extract organization preview components to shared module
1 parent b88cf59 commit a5af7fc

File tree

5 files changed

+638
-1
lines changed

5 files changed

+638
-1
lines changed
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import type { UserOrganizationInvitationResource } from '@clerk/types';
2+
import type { PropsWithChildren } from 'react';
3+
import { forwardRef } from 'react';
4+
5+
import type { ElementDescriptor } from '@/ui/customizables/elementDescriptors';
6+
import { OrganizationPreview } from '@/ui/elements/OrganizationPreview';
7+
import { PreviewButton } from '@/ui/elements/PreviewButton';
8+
9+
import { Box, Button, Col, descriptors, Flex, Spinner } from '../../customizables';
10+
import { SwitchArrowRight } from '../../icons';
11+
import type { ThemableCssProp } from '../../styledSystem';
12+
import { common } from '../../styledSystem';
13+
14+
export const OrganizationPreviewListItems = (props: PropsWithChildren) => {
15+
return (
16+
<Col
17+
elementDescriptor={descriptors.organizationListPreviewItems}
18+
sx={t => ({
19+
maxHeight: `calc(8 * ${t.sizes.$12})`,
20+
overflowY: 'auto',
21+
borderTopWidth: t.borderWidths.$normal,
22+
borderTopStyle: t.borderStyles.$solid,
23+
borderTopColor: t.colors.$borderAlpha100,
24+
...common.unstyledScrollbar(t),
25+
})}
26+
>
27+
{props.children}
28+
</Col>
29+
);
30+
};
31+
32+
const sharedStyles: ThemableCssProp = t => ({
33+
padding: `${t.space.$4} ${t.space.$5}`,
34+
});
35+
36+
export const sharedMainIdentifierSx: ThemableCssProp = t => ({
37+
color: t.colors.$colorForeground,
38+
':hover': {
39+
color: t.colors.$colorForeground,
40+
},
41+
});
42+
43+
type OrganizationPreviewListItemProps = PropsWithChildren<{
44+
elementId: React.ComponentProps<typeof OrganizationPreview>['elementId'];
45+
organizationData: UserOrganizationInvitationResource['publicOrganizationData'];
46+
}>;
47+
48+
export const OrganizationPreviewListItem = ({
49+
children,
50+
elementId,
51+
organizationData,
52+
}: OrganizationPreviewListItemProps) => {
53+
return (
54+
<Flex
55+
align='center'
56+
gap={2}
57+
sx={[
58+
t => ({
59+
minHeight: 'unset',
60+
justifyContent: 'space-between',
61+
borderTopWidth: t.borderWidths.$normal,
62+
borderTopStyle: t.borderStyles.$solid,
63+
borderTopColor: t.colors.$borderAlpha100,
64+
}),
65+
sharedStyles,
66+
]}
67+
elementDescriptor={descriptors.organizationListPreviewItem}
68+
>
69+
<OrganizationPreview
70+
elementId={elementId}
71+
mainIdentifierSx={sharedMainIdentifierSx}
72+
organization={organizationData}
73+
/>
74+
{children}
75+
</Flex>
76+
);
77+
};
78+
79+
export const OrganizationPreviewListSpinner = forwardRef<HTMLDivElement>((_, ref) => {
80+
return (
81+
<Box
82+
ref={ref}
83+
sx={t => ({
84+
width: '100%',
85+
height: t.space.$12,
86+
position: 'relative',
87+
})}
88+
>
89+
<Box
90+
sx={{
91+
margin: 'auto',
92+
position: 'absolute',
93+
left: '50%',
94+
top: '50%',
95+
transform: 'translateY(-50%) translateX(-50%)',
96+
}}
97+
>
98+
<Spinner
99+
size='sm'
100+
colorScheme='primary'
101+
elementDescriptor={descriptors.spinner}
102+
/>
103+
</Box>
104+
</Box>
105+
);
106+
});
107+
108+
export const OrganizationPreviewListItemButton = (props: Parameters<typeof Button>[0]) => {
109+
return (
110+
<Button
111+
textVariant='buttonSmall'
112+
variant='outline'
113+
size='xs'
114+
{...props}
115+
/>
116+
);
117+
};
118+
119+
type OrganizationListPreviewButtonProps = PropsWithChildren<{
120+
onClick: () => void | Promise<void>;
121+
elementDescription: ElementDescriptor;
122+
}>;
123+
124+
// TODO - Reuse those components on OrganizationList as well
125+
export const OrganizationListPreviewButton = (props: OrganizationListPreviewButtonProps) => {
126+
return (
127+
<PreviewButton
128+
elementDescriptor={props.elementDescription}
129+
sx={[sharedStyles]}
130+
icon={SwitchArrowRight}
131+
{...props}
132+
/>
133+
);
134+
};
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { useClerk, useOrganizationList } from '@clerk/shared/react';
2+
import type { CreateOrganizationParams } from '@clerk/types';
3+
import React, { useState } from 'react';
4+
5+
import { OrganizationAvatarUploader } from '@/ui/common/organizations/OrganizationAvatarUploader';
6+
import { useSessionTasksContext } from '@/ui/contexts/components/SessionTasks';
7+
import { Col, descriptors, Icon, localizationKeys } from '@/ui/customizables';
8+
import { useCardState, withCardStateProvider } from '@/ui/elements/contexts';
9+
import { Form } from '@/ui/elements/Form';
10+
import { FormButtonContainer } from '@/ui/elements/FormButtons';
11+
import { FormContainer } from '@/ui/elements/FormContainer';
12+
import { IconButton } from '@/ui/elements/IconButton';
13+
import { Organization } from '@/ui/icons';
14+
import { createSlug } from '@/ui/utils/createSlug';
15+
import { handleError } from '@/ui/utils/errorHandler';
16+
import { useFormControl } from '@/ui/utils/useFormControl';
17+
18+
import { organizationListParams } from '../../../OrganizationSwitcher/utils';
19+
20+
type CreateOrganizationScreenProps = {
21+
onCancel?: () => void;
22+
hideSlug?: boolean;
23+
};
24+
25+
export const CreateOrganizationScreen = withCardStateProvider((props: CreateOrganizationScreenProps) => {
26+
const card = useCardState();
27+
const { __internal_navigateToTaskIfAvailable } = useClerk();
28+
const { redirectUrlComplete } = useSessionTasksContext();
29+
const { createOrganization, isLoaded, setActive } = useOrganizationList({
30+
userMemberships: organizationListParams.userMemberships,
31+
});
32+
33+
const [file, setFile] = useState<File | null>();
34+
const nameField = useFormControl('name', '', {
35+
type: 'text',
36+
label: localizationKeys('formFieldLabel__organizationName'),
37+
placeholder: localizationKeys('formFieldInputPlaceholder__organizationName'),
38+
});
39+
const slugField = useFormControl('slug', '', {
40+
type: 'text',
41+
label: localizationKeys('formFieldLabel__organizationSlug'),
42+
placeholder: localizationKeys('formFieldInputPlaceholder__organizationSlug'),
43+
});
44+
45+
const onSubmit = async (e: React.FormEvent) => {
46+
e.preventDefault();
47+
48+
if (!isLoaded) {
49+
return;
50+
}
51+
52+
try {
53+
const createOrgParams: CreateOrganizationParams = { name: nameField.value };
54+
55+
if (!props.hideSlug) {
56+
// TODO -> Should we always show the slug
57+
// should it be exposed as a prop on TaskSelectOrganization
58+
createOrgParams.slug = slugField.value;
59+
}
60+
61+
const organization = await createOrganization(createOrgParams);
62+
63+
if (file) {
64+
await organization.setLogo({ file });
65+
}
66+
67+
await setActive({ organization });
68+
69+
await __internal_navigateToTaskIfAvailable({ redirectUrlComplete });
70+
} catch (err) {
71+
handleError(err, [nameField, slugField], card.setError);
72+
}
73+
};
74+
75+
const onAvatarRemove = () => {
76+
card.setIdle();
77+
return setFile(null);
78+
};
79+
80+
const onChangeName = (event: React.ChangeEvent<HTMLInputElement>) => {
81+
nameField.setValue(event.target.value);
82+
updateSlugField(createSlug(event.target.value));
83+
};
84+
85+
const updateSlugField = (val: string) => {
86+
slugField.setValue(val);
87+
};
88+
89+
const isSubmitButtonDisabled = !nameField.value || !isLoaded;
90+
91+
return (
92+
<FormContainer>
93+
<Form.Root onSubmit={onSubmit}>
94+
<Col>
95+
<OrganizationAvatarUploader
96+
organization={{ name: nameField.value }}
97+
onAvatarChange={async file => setFile(file)}
98+
onAvatarRemove={file ? onAvatarRemove : null}
99+
actionTitle={localizationKeys('taskSelectOrganization.createOrganizationScreen.action__uploadAvatar')}
100+
imageTitle={localizationKeys('taskSelectOrganization.createOrganizationScreen.avatarLabel')}
101+
elementDescriptor={descriptors.organizationAvatarUploaderContainer}
102+
avatarPreviewPlaceholder={
103+
<IconButton
104+
variant='ghost'
105+
aria-label='Upload organization logo'
106+
icon={
107+
<Icon
108+
size='md'
109+
icon={Organization}
110+
sx={t => ({
111+
color: t.colors.$colorMutedForeground,
112+
transitionDuration: t.transitionDuration.$controls,
113+
})}
114+
/>
115+
}
116+
sx={t => ({
117+
width: t.sizes.$16,
118+
height: t.sizes.$16,
119+
borderRadius: t.radii.$md,
120+
borderWidth: t.borderWidths.$normal,
121+
borderStyle: t.borderStyles.$dashed,
122+
borderColor: t.colors.$borderAlpha200,
123+
backgroundColor: t.colors.$neutralAlpha50,
124+
':hover': {
125+
backgroundColor: t.colors.$neutralAlpha50,
126+
svg: {
127+
transform: 'scale(1.2)',
128+
},
129+
},
130+
})}
131+
/>
132+
}
133+
/>
134+
</Col>
135+
<Form.ControlRow elementId={nameField.id}>
136+
<Form.PlainInput
137+
{...nameField.props}
138+
onChange={onChangeName}
139+
isRequired
140+
// TODO -> Remove auto focus?
141+
autoFocus
142+
ignorePasswordManager
143+
/>
144+
</Form.ControlRow>
145+
{!props.hideSlug && (
146+
<Form.ControlRow elementId={slugField.id}>
147+
<Form.PlainInput
148+
{...slugField.props}
149+
onChange={event => updateSlugField(event.target.value)}
150+
isRequired
151+
pattern='^(?=.*[a-z0-9])[a-z0-9\-]+$'
152+
ignorePasswordManager
153+
/>
154+
</Form.ControlRow>
155+
)}
156+
<FormButtonContainer sx={t => ({ marginTop: t.space.$none, flexDirection: 'column' })}>
157+
<Form.SubmitButton
158+
block={false}
159+
sx={() => ({ width: '100%' })}
160+
isDisabled={isSubmitButtonDisabled}
161+
localizationKey={localizationKeys('taskSelectOrganization.createOrganizationScreen.formButtonSubmit')}
162+
/>
163+
{props.onCancel && (
164+
<Form.ResetButton
165+
localizationKey={localizationKeys('taskSelectOrganization.createOrganizationScreen.formButtonReset')}
166+
block={false}
167+
onClick={props.onCancel}
168+
/>
169+
)}
170+
</FormButtonContainer>
171+
</Form.Root>
172+
</FormContainer>
173+
);
174+
});

0 commit comments

Comments
 (0)