Skip to content

Commit 56ffb6c

Browse files
authored
Merge pull request #3578 from Shopify/icon-preview
Add icon preview to Admin UI Extensions documentation
2 parents 4130095 + dcbe67c commit 56ffb6c

File tree

6 files changed

+283
-0
lines changed

6 files changed

+283
-0
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import fs from 'fs/promises';
2+
import path from 'path';
3+
import {fileURLToPath} from 'url';
4+
import ts from 'typescript';
5+
6+
const __filename = fileURLToPath(import.meta.url);
7+
const __dirname = path.dirname(__filename);
8+
9+
const componentsPath = path.join(
10+
__dirname,
11+
'../../../src/surfaces/admin/components.d.ts',
12+
);
13+
14+
export async function extractIconList() {
15+
const content = await fs.readFile(componentsPath, 'utf-8');
16+
17+
const sourceFile = ts.createSourceFile(
18+
'components.d.ts',
19+
content,
20+
ts.ScriptTarget.Latest,
21+
true,
22+
);
23+
24+
let icons = [];
25+
26+
function visit(node) {
27+
if (ts.isTypeAliasDeclaration(node) && node.name.text === 'IconType$1') {
28+
if (ts.isUnionTypeNode(node.type)) {
29+
icons = node.type.types
30+
.filter((type) => ts.isLiteralTypeNode(type))
31+
.map((type) => {
32+
if (
33+
ts.isStringLiteral(type.literal) ||
34+
(type.literal && type.literal.text)
35+
) {
36+
return type.literal.text;
37+
}
38+
return null;
39+
})
40+
.filter(Boolean);
41+
}
42+
return;
43+
}
44+
45+
ts.forEachChild(node, visit);
46+
}
47+
48+
visit(sourceFile);
49+
50+
if (icons.length > 0) {
51+
return icons;
52+
}
53+
54+
throw new Error(
55+
'Could not find IconType$1 type definition in components.d.ts',
56+
);
57+
}
58+
59+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
60+
extractIconList().then((icons) => {
61+
console.log(JSON.stringify(icons, null, 2));
62+
});
63+
}

packages/ui-extensions/docs/surfaces/admin/build-docs.mjs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
copyGeneratedToShopifyDev,
1010
replaceFileContent,
1111
} from '../build-doc-shared.mjs';
12+
import {extractIconList} from './build-doc-extract-icons.mjs';
1213

1314
const EXTENSIONS_API_VERSION = process.argv[2] || 'unstable';
1415

@@ -48,6 +49,10 @@ const decodeHTML = (str) => {
4849
.replace(/'/g, "'");
4950
};
5051

52+
const escapeForJSTemplate = (str) => {
53+
return str.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\$');
54+
};
55+
5156
const composeStyles = (...styles) => {
5257
return styles
5358
.filter(Boolean)
@@ -79,6 +84,30 @@ const stylesToString = (styles) => {
7984
.join('; ');
8085
};
8186

87+
const renderJsxTemplate = async (iconPreviewData) => {
88+
const templatePath = path.join(docsPath, 'templates/jsx-render.html');
89+
const template = await fs.readFile(templatePath, 'utf-8');
90+
91+
// Read JSX code from file
92+
const jsxFilePath = path.join(docsPath, iconPreviewData.jsxFile);
93+
let jsxCode = await fs.readFile(jsxFilePath, 'utf-8');
94+
95+
// Replace __ICON_LIST__ placeholder in jsxCode
96+
// Use single quotes to avoid escaping issues in HTML attributes
97+
const iconListString = `[${iconPreviewData.icons
98+
.map((icon) => `'${icon}'`)
99+
.join(',')}]`;
100+
jsxCode = jsxCode.replace(/"__ICON_LIST__"/g, iconListString);
101+
102+
// Escape the JSX code for use in template literal
103+
const escapedJsxCode = escapeForJSTemplate(jsxCode);
104+
105+
return template
106+
.replace(/\{\{COMPOSED_STYLES\}\}/g, iconPreviewData.customStyles || '')
107+
.replace(/\{\{BODY_CONTENT\}\}/g, iconPreviewData.bodyContent || '')
108+
.replace(/\{\{JSX_CODE\}\}/g, escapedJsxCode);
109+
};
110+
82111
const htmlWrapper = (htmlString, layoutStyles = '', customStyles = '') => {
83112
const baseStyles = 'box-sizing: border-box; margin: 0; padding: 0.5rem;';
84113
const composedStyles = composeStyles(baseStyles, layoutStyles, customStyles);
@@ -221,6 +250,30 @@ const templates = {
221250
const transformJson = async (filePath, isExtensions) => {
222251
let jsonData = JSON.parse((await fs.readFile(filePath, 'utf8')).toString());
223252

253+
for (const entry of jsonData) {
254+
if (entry.name === 'Icon' && entry.subSections) {
255+
const iconDataPath = path.join(srcPath, 'components/Icon/icon-data.json');
256+
const iconData = JSON.parse(await fs.readFile(iconDataPath, 'utf-8'));
257+
const iconPreviewData = iconData.iconPreviewData;
258+
259+
if (iconPreviewData.icons === '__AUTO_GENERATED_ICONS__') {
260+
iconPreviewData.icons = await extractIconList();
261+
}
262+
for (const subSection of entry.subSections) {
263+
if (subSection.sectionContent?.includes('{{ICON_PREVIEW_IFRAME}}')) {
264+
const renderedHtml = await renderJsxTemplate(iconPreviewData);
265+
const base64Html = Buffer.from(renderedHtml, 'utf-8').toString(
266+
'base64',
267+
);
268+
subSection.sectionContent = subSection.sectionContent.replace(
269+
/\{\{ICON_PREVIEW_IFRAME\}\}/g,
270+
`<iframe width="100%" height="490px" sandbox="allow-scripts" src="data:text/html;base64,${base64Html}"></iframe>`,
271+
);
272+
}
273+
}
274+
}
275+
}
276+
224277
jsonData.forEach((entry) => {
225278
// Temporary to ensure that isOptional is added to all members
226279
if (entry.definitions && entry.isVisualComponent) {
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
const icons = "__ICON_LIST__";
2+
const [searchQuery, setSearchQuery] = useState('');
3+
const [currentPage, setCurrentPage] = useState(1);
4+
const pageSize = 10;
5+
6+
const filteredIcons = searchQuery
7+
? icons.filter((icon) =>
8+
icon.toLowerCase().includes(searchQuery.toLowerCase())
9+
)
10+
: icons;
11+
12+
const totalPages = Math.ceil(filteredIcons.length / pageSize);
13+
const startIndex = (currentPage - 1) * pageSize;
14+
const currentIcons = filteredIcons.slice(
15+
startIndex,
16+
startIndex + pageSize
17+
);
18+
19+
const handleSearchChange = (e) => {
20+
setSearchQuery(e.target.value);
21+
setCurrentPage(1);
22+
};
23+
24+
const changePage = (newPage) => {
25+
setCurrentPage(Math.min(Math.max(newPage, 1), totalPages));
26+
};
27+
28+
return (
29+
<s-box border="base" padding="base" borderRadius="base">
30+
<s-stack gap="base">
31+
<s-stack direction="inline" gap="small" alignItems="center">
32+
<s-search-field
33+
value={searchQuery}
34+
onInput={handleSearchChange}
35+
placeholder="Search icons..."
36+
label="Search"
37+
labelAccessibilityVisibility="exclusive"
38+
/>
39+
<s-text color="subdued">
40+
{filteredIcons.length}{" "}
41+
{filteredIcons.length === 1 ? "icon" : "icons"}
42+
</s-text>
43+
</s-stack>
44+
45+
{currentIcons.length > 0 ? (
46+
<s-grid
47+
gridTemplateColumns="repeat(2, 1fr)"
48+
gap="base"
49+
>
50+
{currentIcons.map((icon) => (
51+
<s-section key={icon}>
52+
<s-stack gap="small-200" direction="inline" alignItems="center">
53+
<s-icon type={icon} />
54+
<s-paragraph>
55+
<s-text>{icon}</s-text>
56+
</s-paragraph>
57+
</s-stack>
58+
</s-section>
59+
))}
60+
</s-grid>
61+
) : (
62+
<s-stack gap="small" alignItems="center">
63+
<s-paragraph>
64+
<s-text>No icons found matching "{searchQuery}"</s-text>
65+
</s-paragraph>
66+
</s-stack>
67+
)}
68+
{totalPages > 1 && (
69+
<s-stack
70+
direction="inline"
71+
gap="small"
72+
alignItems="center"
73+
justifyContent="center"
74+
>
75+
<s-button
76+
onClick={() => changePage(1)}
77+
disabled={currentPage === 1}
78+
variant="secondary"
79+
>
80+
First
81+
</s-button>
82+
<s-button
83+
onClick={() => changePage(currentPage - 1)}
84+
disabled={currentPage === 1}
85+
variant="secondary"
86+
>
87+
Previous
88+
</s-button>
89+
<s-text>
90+
Page {currentPage} of {totalPages}
91+
</s-text>
92+
<s-button
93+
onClick={() => changePage(currentPage + 1)}
94+
disabled={currentPage === totalPages}
95+
variant="secondary"
96+
>
97+
Next
98+
</s-button>
99+
<s-button
100+
onClick={() => changePage(totalPages)}
101+
disabled={currentPage === totalPages}
102+
variant="secondary"
103+
>
104+
Last
105+
</s-button>
106+
</s-stack>
107+
)}
108+
</s-stack>
109+
</s-box>
110+
)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<style>
5+
html, body {height:100%} body {{{COMPOSED_STYLES}}}
6+
</style>
7+
<script src="https://cdn.shopify.com/shopifycloud/polaris.js"></script>
8+
<script src="https://cdn.shopify.com/shopifycloud/jsx-builder/jsx-builder.min.js"></script>
9+
</head>
10+
<body>
11+
{{BODY_CONTENT}}
12+
<script>
13+
(function () {
14+
const {render, h, Fragment, Component, useState} = window.preact;
15+
const jsxCode = `const App = () => { {{JSX_CODE}} };`;
16+
try {
17+
const {code} = window.sucrase.transform(jsxCode, {
18+
transforms: ['jsx'],
19+
jsxPragma: 'h',
20+
jsxFragmentPragma: 'Fragment',
21+
production: true,
22+
});
23+
const fn = new Function(
24+
'h',
25+
'Fragment',
26+
'Component',
27+
'useState',
28+
code + '; return App;',
29+
);
30+
const App = fn(h, Fragment, Component, useState);
31+
const target =
32+
document.getElementById('wrapper-element') || document.body;
33+
if (target) render(h(App), target);
34+
} catch (e) {
35+
console.error('JSX Transform Error:', e);
36+
}
37+
})();
38+
</script>
39+
</body>
40+
</html>

packages/ui-extensions/src/surfaces/admin/components/Icon/Icon.doc.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ const data: AdminReferenceEntityTemplateSchema = {
55
...sharedContent,
66
thumbnail: '/assets/templated-apis-screenshots/admin/components/icon.png',
77
isVisualComponent: true,
8+
subSections: [
9+
{
10+
title: 'Available icons',
11+
type: 'Generic' as const,
12+
anchorLink: 'available-icons',
13+
sectionContent:
14+
'Search and filter across all the available icons: {{ICON_PREVIEW_IFRAME}}',
15+
},
16+
],
817
definitions: [
918
{
1019
title: 'Properties',
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"iconPreviewData": {
3+
"jsxFile": "./templates/icon-preview.jsx",
4+
"icons": "__AUTO_GENERATED_ICONS__",
5+
"bodyContent": "<div id=\"app\"></div>",
6+
"customStyles": "padding: 0px; margin: 0px; margin-top: 8px; max-width: 584px; overflow: hidden;"
7+
}
8+
}

0 commit comments

Comments
 (0)