@liferay-react/create-react-liferay-client-extension 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +113 -0
- package/package.json +33 -0
- package/template/client-extension.yaml +48 -0
- package/template/package.json +17 -0
- package/template/src/main.jsx +35 -0
- package/template/src/page/App.jsx +21 -0
- package/template/src/page/app.scss +1 -0
- package/template/src/services/services.js +12 -0
- package/template/src/shared/ui/Button.jsx +19 -0
- package/template/src/shared/ui/Card.jsx +6 -0
- package/template/src/shared/ui/EmptyState.jsx +16 -0
- package/template/src/shared/ui/Pagination.jsx +18 -0
- package/template/src/shared/ui/TextField.jsx +23 -0
- package/template/vite.config.js +32 -0
package/bin/cli.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { Command } = require('commander');
|
|
4
|
+
const inquirer = require('inquirer');
|
|
5
|
+
const fs = require('fs-extra');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const chalk = require('chalk');
|
|
8
|
+
|
|
9
|
+
const program = new Command();
|
|
10
|
+
|
|
11
|
+
program
|
|
12
|
+
.version('1.0.0')
|
|
13
|
+
.description('CLI to create a React Liferay Client Extension template')
|
|
14
|
+
.argument('[name]', 'Project name')
|
|
15
|
+
.action(async (name) => {
|
|
16
|
+
let projectName = name;
|
|
17
|
+
|
|
18
|
+
if (!projectName) {
|
|
19
|
+
const answers = await inquirer.prompt([
|
|
20
|
+
{
|
|
21
|
+
type: 'input',
|
|
22
|
+
name: 'name',
|
|
23
|
+
message: 'What is the name of your project?',
|
|
24
|
+
default: 'React Exemplo',
|
|
25
|
+
validate: (input) => {
|
|
26
|
+
if (input.trim().length > 0) return true;
|
|
27
|
+
return 'Project name cannot be empty.';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
]);
|
|
31
|
+
projectName = answers.name;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Helper for normalization (remove accents)
|
|
35
|
+
const normalize = (str) => str.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
|
36
|
+
|
|
37
|
+
// 1. Define names for replacement
|
|
38
|
+
const titleName = projectName; // Keep original for title
|
|
39
|
+
|
|
40
|
+
// Create kebab-case: remove accents, spaces to hyphens, lowercase, remove non-alphanumeric except hyphen
|
|
41
|
+
const kebabName = normalize(projectName)
|
|
42
|
+
.toLowerCase()
|
|
43
|
+
.trim()
|
|
44
|
+
.replace(/\s+/g, '-')
|
|
45
|
+
.replace(/[^a-z0-9\-]/g, '');
|
|
46
|
+
|
|
47
|
+
// Create PascalCase: remove accents, capitalize each word, remove non-alphanumeric
|
|
48
|
+
const pascalName = normalize(projectName)
|
|
49
|
+
.split(/[\s\-]+/)
|
|
50
|
+
.map(part => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
|
51
|
+
.join('')
|
|
52
|
+
.replace(/[^a-zA-Z0-9]/g, '');
|
|
53
|
+
|
|
54
|
+
const targetDir = path.join(process.cwd(), kebabName);
|
|
55
|
+
|
|
56
|
+
if (fs.existsSync(targetDir)) {
|
|
57
|
+
console.error(chalk.red(`Error: Directory ${kebabName} already exists.`));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
console.log(chalk.blue(`Creating project in ${targetDir}...`));
|
|
62
|
+
|
|
63
|
+
const templateDir = path.join(__dirname, '..', 'template');
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
// 1. Copy template
|
|
67
|
+
await fs.copy(templateDir, targetDir);
|
|
68
|
+
|
|
69
|
+
// 2. Walk through files and replace content
|
|
70
|
+
await transformDirectory(targetDir, kebabName, pascalName, titleName);
|
|
71
|
+
|
|
72
|
+
console.log(chalk.green('\nProject created successfully!'));
|
|
73
|
+
console.log(`\nTo get started:\n cd ${kebabName}\n npm install\n npm run dev`);
|
|
74
|
+
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error(chalk.red('Error creating project:'), err);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
async function transformDirectory(dir, kebab, pascal, title) {
|
|
82
|
+
const files = await fs.readdir(dir);
|
|
83
|
+
|
|
84
|
+
for (const file of files) {
|
|
85
|
+
const fullPath = path.join(dir, file);
|
|
86
|
+
const stat = await fs.stat(fullPath);
|
|
87
|
+
|
|
88
|
+
if (stat.isDirectory()) {
|
|
89
|
+
await transformDirectory(fullPath, kebab, pascal, title);
|
|
90
|
+
} else {
|
|
91
|
+
await transformFile(fullPath, kebab, pascal, title);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function transformFile(filePath, kebab, pascal, title) {
|
|
97
|
+
// Read file
|
|
98
|
+
let content = await fs.readFile(filePath, 'utf8');
|
|
99
|
+
|
|
100
|
+
// Replace placeholders
|
|
101
|
+
// react-exemplo -> kebab
|
|
102
|
+
// ReactExemplo -> pascal
|
|
103
|
+
// React Exemplo -> title
|
|
104
|
+
|
|
105
|
+
content = content.replace(/react-exemplo/g, kebab);
|
|
106
|
+
content = content.replace(/ReactExemplo/g, pascal);
|
|
107
|
+
content = content.replace(/React Exemplo/g, title);
|
|
108
|
+
|
|
109
|
+
// Write file back
|
|
110
|
+
await fs.writeFile(filePath, content, 'utf8');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
program.parse(process.argv);
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@liferay-react/create-react-liferay-client-extension",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "CLI to create a React Liferay Client Extension template",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "bin/cli.js",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"liferay",
|
|
9
|
+
"react",
|
|
10
|
+
"boilerplate",
|
|
11
|
+
"generator",
|
|
12
|
+
"cli",
|
|
13
|
+
"dxp",
|
|
14
|
+
"portlet",
|
|
15
|
+
"npm-bundler",
|
|
16
|
+
"frontend"
|
|
17
|
+
],
|
|
18
|
+
"bin": {
|
|
19
|
+
"create-client-react": "./bin/cli.js"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"chalk": "^4.1.2",
|
|
29
|
+
"commander": "^11.1.0",
|
|
30
|
+
"fs-extra": "^11.1.1",
|
|
31
|
+
"inquirer": "^8.2.5"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
assemble:
|
|
2
|
+
- from: build/vite
|
|
3
|
+
into: static
|
|
4
|
+
|
|
5
|
+
react-exemplo:
|
|
6
|
+
name: React Exemplo
|
|
7
|
+
type: customElement
|
|
8
|
+
htmlElementName: react-exemplo
|
|
9
|
+
friendlyURLMapping: react-exemplo
|
|
10
|
+
instanceable: true
|
|
11
|
+
portletCategoryName: "Empresas"
|
|
12
|
+
virtualInstanceSupport: true
|
|
13
|
+
dxp.lxc.liferay.com.virtualInstanceId: "empresas-hml.unimedcnu.coop.br"
|
|
14
|
+
urls:
|
|
15
|
+
- "*.js"
|
|
16
|
+
useESM: true
|
|
17
|
+
properties:
|
|
18
|
+
url: ""
|
|
19
|
+
|
|
20
|
+
react-exemplo-dev:
|
|
21
|
+
name: React Exemplo
|
|
22
|
+
type: customElement
|
|
23
|
+
htmlElementName: react-exemplo
|
|
24
|
+
friendlyURLMapping: react-exemplo
|
|
25
|
+
instanceable: true
|
|
26
|
+
portletCategoryName: "Empresas"
|
|
27
|
+
virtualInstanceSupport: true
|
|
28
|
+
dxp.lxc.liferay.com.virtualInstanceId: "empresas-dev.unimedcnu.coop.br"
|
|
29
|
+
urls:
|
|
30
|
+
- "*.js"
|
|
31
|
+
useESM: true
|
|
32
|
+
properties:
|
|
33
|
+
url: ""
|
|
34
|
+
|
|
35
|
+
react-exemplo-prd:
|
|
36
|
+
name: React Exemplo
|
|
37
|
+
type: customElement
|
|
38
|
+
htmlElementName: react-exemplo
|
|
39
|
+
friendlyURLMapping: react-exemplo
|
|
40
|
+
instanceable: true
|
|
41
|
+
portletCategoryName: "Empresas"
|
|
42
|
+
virtualInstanceSupport: true
|
|
43
|
+
dxp.lxc.liferay.com.virtualInstanceId: "default"
|
|
44
|
+
urls:
|
|
45
|
+
- "*.js"
|
|
46
|
+
useESM: true
|
|
47
|
+
properties:
|
|
48
|
+
url: ""
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-exemplo",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"build": "vite build",
|
|
6
|
+
"dev": "vite"
|
|
7
|
+
},
|
|
8
|
+
"devDependencies": {
|
|
9
|
+
"@tanstack/react-query": "^5.100.10",
|
|
10
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
11
|
+
"react": "^19.0.0",
|
|
12
|
+
"react-dom": "^19.0.0",
|
|
13
|
+
"sass": "^1.99.0",
|
|
14
|
+
"vite": "^5.0.0",
|
|
15
|
+
"vite-plugin-css-injected-by-js": "^4.0.1"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createRoot } from "react-dom/client";
|
|
2
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
3
|
+
import { App } from "./page/App";
|
|
4
|
+
|
|
5
|
+
const queryClient = new QueryClient({
|
|
6
|
+
defaultOptions: {
|
|
7
|
+
queries: {
|
|
8
|
+
retry: false,
|
|
9
|
+
refetchOnWindowFocus: false,
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
class ReactExemplo extends HTMLElement {
|
|
15
|
+
#root = null;
|
|
16
|
+
|
|
17
|
+
connectedCallback() {
|
|
18
|
+
const props = {
|
|
19
|
+
url: this.getAttribute("url"),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
this.#root = createRoot(this);
|
|
23
|
+
this.#root.render(
|
|
24
|
+
<QueryClientProvider client={queryClient}>
|
|
25
|
+
<App {...props} />
|
|
26
|
+
</QueryClientProvider>,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
disconnectedCallback() {
|
|
31
|
+
this.#root?.unmount();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
customElements.define("react-exemplo", ReactExemplo);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { getExemplo } from "../services/services";
|
|
4
|
+
import "./app.scss";
|
|
5
|
+
|
|
6
|
+
export const App = () => {
|
|
7
|
+
const [search, setSearch] = useState("");
|
|
8
|
+
const [filter, setFilter] = useState({
|
|
9
|
+
page: 1,
|
|
10
|
+
pageSize: 5,
|
|
11
|
+
});
|
|
12
|
+
const debouncedSearch = useDebounce(search, 400);
|
|
13
|
+
const queryFilter = { ...filter, search: debouncedSearch };
|
|
14
|
+
|
|
15
|
+
const { data, isLoading, isError } = useQuery({
|
|
16
|
+
queryKey: ["exemplo-query", queryFilter],
|
|
17
|
+
queryFn: () => getExemplo(queryFilter),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
return <div></div>;
|
|
21
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
|
|
3
|
+
const api = axios.create({
|
|
4
|
+
baseURL: "",
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
export async function getExemplo({ page = 1, pageSize = 5, search = "" } = {}) {
|
|
8
|
+
const response = await api.get("/exemplo", {
|
|
9
|
+
params: { page, pageSize, search },
|
|
10
|
+
});
|
|
11
|
+
return response.data;
|
|
12
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Button as ButtonUI } from "@unimed/components";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Componente de botão customizado da Unimed.
|
|
6
|
+
*
|
|
7
|
+
* @param {Object} props - Propriedades do componente.
|
|
8
|
+
* @param {React.ReactNode} props.children - Conteúdo interno do botão.
|
|
9
|
+
* @param {"small" | "large"} [props.size] - Tamanho do botão. O padrão é médio.
|
|
10
|
+
* @param {"secondary" | "tertiary"} [props.variant] - Variante visual do botão. O padrão é primary.
|
|
11
|
+
* @param {string} [props.className] - Classes CSS adicionais.
|
|
12
|
+
* @param {string} [props.iconLeft] - Nome do ícone do Material Symbols para exibir à esquerda.
|
|
13
|
+
* @param {string} [props.iconRight] - Nome do ícone do Material Symbols para exibir à direita.
|
|
14
|
+
* @param {boolean} [props.loading] - Estado de carregamento que exibe um spinner.
|
|
15
|
+
* @param {React.ButtonHTMLAttributes<HTMLButtonElement>} props - Todas as outras propriedades nativas de um botão HTML.
|
|
16
|
+
*/
|
|
17
|
+
const Button = (props) => <ButtonUI {...props} />;
|
|
18
|
+
|
|
19
|
+
export { Button };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { EmptyState as EmptyStateUI } from "@unimed/components";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Componente para exibição de estados vazios ou de erro.
|
|
6
|
+
*
|
|
7
|
+
* Oferece variantes visuais predefinidas com ícones e textos adequados para cada situação.
|
|
8
|
+
*
|
|
9
|
+
* @param {Object} props - Propriedades do componente.
|
|
10
|
+
* @param {"empty" | "error"} [props.variant] - Variante do estado (padrão: "empty").
|
|
11
|
+
* @param {string} [props.title] - Título customizado (sobrescreve o padrão da variante).
|
|
12
|
+
* @param {string} [props.description] - Descrição customizada (sobrescreve o padrão da variante).
|
|
13
|
+
*/
|
|
14
|
+
const EmptyState = (props) => <EmptyStateUI {...props} />;
|
|
15
|
+
|
|
16
|
+
export { EmptyState };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Pagination as PaginationUI } from "@unimed/components";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Componente de Paginação para listagem de dados.
|
|
6
|
+
*
|
|
7
|
+
* @param {Object} props - Propriedades do componente.
|
|
8
|
+
* @param {number} props.page - Página atual (1-indexed).
|
|
9
|
+
* @param {number} props.totalPages - Total de páginas disponíveis.
|
|
10
|
+
* @param {number} props.pageSize - Quantidade de itens por página.
|
|
11
|
+
* @param {number} props.totalItems - Total de itens na base de dados.
|
|
12
|
+
* @param {(page: number) => void} props.onPageChange - Callback chamado ao trocar de página.
|
|
13
|
+
* @param {(size: number) => void} props.onPageSizeChange - Callback chamado ao trocar a quantidade de itens por página.
|
|
14
|
+
* @param {Array<{label: string, value: number}>} [props.pageSizeOptions] - Opções para o seletor de quantidade de itens.
|
|
15
|
+
*/
|
|
16
|
+
const Pagination = (props) => <PaginationUI {...props} />;
|
|
17
|
+
|
|
18
|
+
export { Pagination };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { TextField as TextFieldUI } from "@unimed/components";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Componente de campo de texto customizado da Unimed.
|
|
6
|
+
*
|
|
7
|
+
* @param {Object} props - Propriedades do componente.
|
|
8
|
+
* @param {string} [props.label] - Rótulo exibido acima do campo.
|
|
9
|
+
* @param {string} [props.placeholder] - Texto de dica dentro do campo.
|
|
10
|
+
* @param {string | number} props.value - Valor atual do campo (controlado).
|
|
11
|
+
* @param {Function} props.onChange - Callback chamado ao alterar o valor.
|
|
12
|
+
* @param {string} [props.type] - Tipo do input (text, password, email, etc).
|
|
13
|
+
* @param {string} [props.iconLeft] - Nome do ícone do Material Symbols para exibir à esquerda.
|
|
14
|
+
* @param {string} [props.iconRight] - Nome do ícone do Material Symbols para exibir à direita.
|
|
15
|
+
* @param {string} [props.helpText] - Texto de auxílio exibido abaixo do campo.
|
|
16
|
+
* @param {string} [props.error] - Mensagem de erro que muda o estado visual do campo.
|
|
17
|
+
* @param {boolean} [props.required] - Indica se o campo é obrigatório.
|
|
18
|
+
* @param {boolean} [props.loading] - Exibe um spinner de carregamento dentro do campo.
|
|
19
|
+
* @param {React.InputHTMLAttributes<HTMLInputElement>} props - Todas as outras propriedades nativas de um input HTML.
|
|
20
|
+
*/
|
|
21
|
+
const TextField = (props) => <TextFieldUI {...props} />;
|
|
22
|
+
|
|
23
|
+
export { TextField };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { defineConfig } from "vite";
|
|
2
|
+
import react from "@vitejs/plugin-react";
|
|
3
|
+
import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js";
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
plugins: [react(), cssInjectedByJsPlugin()],
|
|
8
|
+
define: {
|
|
9
|
+
"process.env.NODE_ENV": '"production"',
|
|
10
|
+
},
|
|
11
|
+
build: {
|
|
12
|
+
outDir: "build/vite",
|
|
13
|
+
lib: {
|
|
14
|
+
entry: resolve(__dirname, "src/main.jsx"),
|
|
15
|
+
formats: ["es"],
|
|
16
|
+
fileName: () => "index.js",
|
|
17
|
+
},
|
|
18
|
+
rollupOptions: {
|
|
19
|
+
external: [
|
|
20
|
+
"axios",
|
|
21
|
+
"react",
|
|
22
|
+
"react-dom/client",
|
|
23
|
+
"react/jsx-runtime",
|
|
24
|
+
/^react\/.*/,
|
|
25
|
+
"@tanstack/react-query",
|
|
26
|
+
"@unimed/components",
|
|
27
|
+
"@unimed/hooks",
|
|
28
|
+
"@unimed/utils",
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
});
|