Skip to content

Commit 64e59d0

Browse files
committed
WIP
1 parent 5ee770d commit 64e59d0

File tree

11 files changed

+436
-29
lines changed

11 files changed

+436
-29
lines changed

demo-saas/admin/src/App.tsx

Lines changed: 56 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,15 @@ import "@src/polyfills";
33

44
import { ApolloProvider } from "@apollo/client";
55
import { ErrorDialogHandler, MasterLayout, MuiThemeProvider, RouterBrowserRouter, SnackbarProvider } from "@comet/admin";
6-
import { CometConfigProvider, type ContentScope, ContentScopeProvider, CurrentUserProvider, MasterMenuRoutes, SitePreview } from "@comet/cms-admin";
6+
import {
7+
CometConfigProvider,
8+
type ContentScope,
9+
ContentScopeProvider,
10+
type ContentScopeValues,
11+
CurrentUserProvider,
12+
MasterMenuRoutes,
13+
SitePreview,
14+
} from "@comet/cms-admin";
715
import { css, Global } from "@emotion/react";
816
import { LocalizationProvider } from "@mui/x-date-pickers";
917
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFnsV3";
@@ -22,6 +30,7 @@ import MasterHeader from "./common/MasterHeader";
2230
import { AppMasterMenu, masterMenuData } from "./common/MasterMenu";
2331
import { type GQLPermission } from "./graphql.generated";
2432
import { getMessages } from "./lang";
33+
import { TenantProvider } from "./tenants/TenantProvider";
2534

2635
const GlobalStyle = () => (
2736
<Global
@@ -64,30 +73,54 @@ export function App() {
6473
<CurrentUserProvider>
6574
<RouterBrowserRouter>
6675
<GlobalStyle />
67-
<ContentScopeProvider>
68-
{({ match }) => (
69-
<Switch>
70-
<Route
71-
path={`${match.path}/preview`}
72-
render={(props) => (
73-
<SitePreview
74-
resolvePath={(path: string, scope) => {
75-
return `/${scope.language}${path}`;
76-
}}
77-
{...props}
76+
<TenantProvider>
77+
{({ tenantId }) => (
78+
<ContentScopeProvider
79+
values={[
80+
{
81+
scope: { department: "98afb709-3d59-4599-9770-a9528d336c39" },
82+
label: { department: "main" },
83+
},
84+
{
85+
scope: { department: "c081fb0e-0744-4547-b96d-ea791214a90e" },
86+
label: { department: "secondary" },
87+
},
88+
]}
89+
defaultValue={{ department: "98afb709-3d59-4599-9770-a9528d336c39" }}
90+
location={{
91+
createPath: (scope: ContentScopeValues) => {
92+
return `/:tenantId/:department`;
93+
},
94+
createUrl: (scope: ContentScope) => {
95+
return `${tenantId}/${scope.department}`;
96+
},
97+
}}
98+
>
99+
{({ match }) => (
100+
<Switch>
101+
<Route
102+
path={`${match.path}/preview`}
103+
render={(props) => (
104+
<SitePreview
105+
resolvePath={(path: string, scope) => {
106+
return `/${scope.language}${path}`;
107+
}}
108+
{...props}
109+
/>
110+
)}
78111
/>
79-
)}
80-
/>
81-
<Route
82-
render={() => (
83-
<MasterLayout headerComponent={MasterHeader} menuComponent={AppMasterMenu}>
84-
<MasterMenuRoutes menu={masterMenuData} />
85-
</MasterLayout>
86-
)}
87-
/>
88-
</Switch>
112+
<Route
113+
render={() => (
114+
<MasterLayout headerComponent={MasterHeader} menuComponent={AppMasterMenu}>
115+
<MasterMenuRoutes menu={masterMenuData} />
116+
</MasterLayout>
117+
)}
118+
/>
119+
</Switch>
120+
)}
121+
</ContentScopeProvider>
89122
)}
90-
</ContentScopeProvider>
123+
</TenantProvider>
91124
</RouterBrowserRouter>
92125
</CurrentUserProvider>
93126
</SnackbarProvider>
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
11
export type ContentScope = {
2-
domain: string;
3-
language: string;
2+
department: string;
43
};

demo-saas/admin/src/common/MasterHeader.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { BuildEntry, ContentScopeControls, Header, UserHeaderItem } from "@comet/cms-admin";
22

3+
import { TenantControls } from "./TenantControls";
4+
35
const MasterHeader = () => {
46
return (
57
<Header>
8+
<TenantControls />
69
<ContentScopeControls />
710
<BuildEntry />
811
<UserHeaderItem />
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { gql, useQuery } from "@apollo/client";
2+
import { type ReactNode } from "react";
3+
import { useHistory } from "react-router";
4+
5+
import { type GQLTenantControlsTenantsQuery, type GQLTenantControlsTenantsQueryVariables } from "./TenantControls.generated";
6+
import { TenantScopeSelect } from "./TenantScopeSelect";
7+
8+
interface ContentScopeControlsProps {
9+
searchable?: boolean;
10+
icon?: ReactNode;
11+
}
12+
13+
export function TenantControls({ searchable, icon }: ContentScopeControlsProps): JSX.Element | null {
14+
const { data, loading } = useQuery<GQLTenantControlsTenantsQuery, GQLTenantControlsTenantsQueryVariables>(tenantsQuery); // TODO: pagination
15+
const tenants = data?.tenants.nodes.map((tenant) => ({ id: tenant.id, name: tenant.name })) ?? [];
16+
17+
const url = new URL(window.location.href);
18+
const tenantId = url.pathname.split("/")[1];
19+
const history = useHistory();
20+
21+
// check if tenantId is in tenants, if not redirect to landing page tenant selector
22+
const tenant = tenants.find((tenant) => tenant.id === tenantId);
23+
if (!tenant && !loading) {
24+
history.push(`/`);
25+
}
26+
27+
function handleChange(tenantId: string) {
28+
history.push(`/${tenantId}`);
29+
}
30+
31+
// Only show tenant select when user has more than one tenant
32+
if (tenants.length > 1) {
33+
return <TenantScopeSelect value={tenantId} onChange={handleChange} options={tenants} icon={icon} searchable={searchable} />;
34+
}
35+
36+
return null;
37+
}
38+
39+
const tenantsQuery = gql`
40+
query TenantControlsTenants {
41+
tenants {
42+
nodes {
43+
id
44+
name
45+
}
46+
}
47+
}
48+
`;
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { AppHeaderDropdown, ClearInputAdornment } from "@comet/admin";
2+
import { Company, Search } from "@comet/admin-icons";
3+
import { findTextMatches, MarkedMatches } from "@comet/cms-admin";
4+
import { Box, Divider, InputAdornment, InputBase, List, ListItem, ListItemButton, ListItemIcon, ListItemText, useTheme } from "@mui/material";
5+
import { type ReactNode, useState } from "react";
6+
import { FormattedMessage, useIntl } from "react-intl";
7+
8+
type TenantOption = {
9+
id: string;
10+
name: string;
11+
};
12+
13+
interface Props {
14+
value: string;
15+
onChange: (value: string) => void;
16+
options: Array<TenantOption>;
17+
searchable?: boolean;
18+
icon?: ReactNode;
19+
}
20+
21+
export function TenantScopeSelect({ value, onChange, options, searchable = true, icon = <Company /> }: Props) {
22+
const intl = useIntl();
23+
const theme = useTheme();
24+
const [searchValue, setSearchValue] = useState<string>("");
25+
let filteredOptions = options;
26+
27+
if (searchable) {
28+
filteredOptions = options.filter((option) => {
29+
return option.name.toLowerCase().includes(searchValue.toLowerCase());
30+
});
31+
}
32+
33+
const selectedOption = options.find((option) => option.id === value);
34+
35+
const handleChange = (selectedId: string) => {
36+
onChange(selectedId);
37+
};
38+
39+
return (
40+
<AppHeaderDropdown
41+
buttonChildren={selectedOption?.name ?? ""}
42+
startIcon={icon}
43+
slotProps={{
44+
root: {
45+
sx: (theme) => ({
46+
overflow: "hidden",
47+
width: "100%",
48+
49+
[theme.breakpoints.up("md")]: {
50+
width: "auto",
51+
},
52+
}),
53+
},
54+
button: {
55+
slotProps: {
56+
root: {
57+
sx: (theme) => ({
58+
width: "100%",
59+
justifyContent: "start",
60+
61+
[theme.breakpoints.up("md")]: {
62+
justifyContent: "center",
63+
},
64+
}),
65+
},
66+
content: {
67+
sx: (theme) => ({
68+
width: "100%",
69+
justifyContent: "start",
70+
71+
[theme.breakpoints.up("md")]: {
72+
justifyContent: "center",
73+
},
74+
}),
75+
},
76+
endIcon: {
77+
sx: (theme) => ({
78+
[theme.breakpoints.up("xs")]: {
79+
"&:not(:first-of-type)": {
80+
marginLeft: "auto",
81+
},
82+
},
83+
84+
[theme.breakpoints.up("md")]: {
85+
"&:not(:first-of-type)": {
86+
marginLeft: theme.spacing(2),
87+
},
88+
},
89+
}),
90+
},
91+
typography: {
92+
sx: {
93+
textOverflow: "ellipsis",
94+
overflow: "hidden",
95+
whiteSpace: "nowrap",
96+
},
97+
},
98+
},
99+
},
100+
popover: {
101+
anchorOrigin: { vertical: "bottom", horizontal: "left" },
102+
transformOrigin: { vertical: "top", horizontal: "left" },
103+
PaperProps: {
104+
sx: (theme) => ({
105+
minWidth: "350px",
106+
maxHeight: "calc(100vh - 60px)",
107+
[theme.breakpoints.down("md")]: {
108+
width: "100%",
109+
maxWidth: "none",
110+
bottom: 0,
111+
},
112+
}),
113+
},
114+
},
115+
}}
116+
>
117+
{(hideDropdown) => (
118+
<>
119+
{searchable && (
120+
<>
121+
<Box sx={{ px: 3, py: 2 }}>
122+
<InputBase
123+
startAdornment={
124+
<InputAdornment position="start">
125+
<Search htmlColor={theme.palette.grey[900]} />
126+
</InputAdornment>
127+
}
128+
placeholder={intl.formatMessage({
129+
id: "tenantScopeSelect.searchInput.placeholder",
130+
defaultMessage: "Search...",
131+
})}
132+
value={searchValue}
133+
onChange={(event) => setSearchValue(event.currentTarget.value)}
134+
endAdornment={
135+
<ClearInputAdornment
136+
onClick={() => setSearchValue("")}
137+
hasClearableContent={searchValue !== ""}
138+
position="end"
139+
slotProps={{
140+
buttonBase: { sx: { fontSize: "16px" } },
141+
}}
142+
/>
143+
}
144+
autoFocus
145+
fullWidth
146+
/>
147+
</Box>
148+
<Divider />
149+
</>
150+
)}
151+
<List sx={{ paddingTop: 0 }}>
152+
{filteredOptions.map((option) => {
153+
const isSelected = option.id === value;
154+
155+
return (
156+
<ListItemButton
157+
key={option.id}
158+
onClick={() => {
159+
hideDropdown();
160+
handleChange(option.id);
161+
setSearchValue("");
162+
}}
163+
selected={isSelected}
164+
sx={({ spacing }) => ({
165+
paddingX: spacing(6),
166+
})}
167+
>
168+
<ListItemIcon>
169+
<Company />
170+
</ListItemIcon>
171+
<ListItemText
172+
slotProps={{
173+
primary: {
174+
variant: isSelected ? "subtitle2" : "body2",
175+
},
176+
}}
177+
sx={{ margin: 0 }}
178+
primary={<MarkedMatches text={option.name} matches={findTextMatches(option.name, searchValue)} />}
179+
/>
180+
</ListItemButton>
181+
);
182+
})}
183+
{filteredOptions.length === 0 && (
184+
<ListItem>
185+
<FormattedMessage id="tenantScopeSelect.list.noOptions" defaultMessage="No options" />
186+
</ListItem>
187+
)}
188+
</List>
189+
</>
190+
)}
191+
</AppHeaderDropdown>
192+
);
193+
}

demo-saas/admin/src/common/apollo/createApolloClient.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ApolloClient, ApolloLink, HttpLink, InMemoryCache } from "@apollo/client";
2+
import { setContext } from "@apollo/client/link/context";
23
import { createErrorDialogApolloLink } from "@comet/admin";
34
import { includeInvisibleContentContext } from "@comet/cms-admin";
45
import fragmentTypes from "@src/fragmentTypes.json";
@@ -9,7 +10,19 @@ export const createApolloClient = (apiUrl: string) => {
910
credentials: "include",
1011
});
1112

12-
const link = ApolloLink.from([createErrorDialogApolloLink(), includeInvisibleContentContext, httpLink]);
13+
const tenantContextLink = setContext((_, { headers }) => {
14+
const url = new URL(window.location.href);
15+
// get first / / of url path
16+
const tenantId = url.pathname.split("/")[1];
17+
return {
18+
headers: {
19+
...headers,
20+
...(tenantId ? { "x-tenant-id": tenantId } : {}),
21+
},
22+
};
23+
});
24+
25+
const link = ApolloLink.from([createErrorDialogApolloLink(), includeInvisibleContentContext, tenantContextLink, httpLink]);
1326

1427
const cache = new InMemoryCache({
1528
possibleTypes: fragmentTypes.possibleTypes,

0 commit comments

Comments
 (0)