[FEAT] Generate barrel export named modules and types (#11110)
# Introduction In this PR using the Ts AST dynamically compute what to export, gathering non-runtime types and interface in an `export type` [Export type TypeScript documentation](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html) From ```ts // index.ts export * from "submodule" ``` To ```ts export type { SomeType } from "submodule"; export { SomeFunction, SomeConst } from "submodule"; ``` close https://github.com/twentyhq/core-team-issues/issues/644 ## Motivations - Most explicit and maintainable - Best for tree-shaking - Clear dependency tracking - Prevents name collisions ## Important note Please keep in mind that I will create, very soon, a dedicated `generate-barrel` package in our yarn workspaces in order to: - Make it reusable for twenty-ui - Split in several files - Setup lint + tsconfig - Add tests ## Conclusion As usual any suggestions are more than welcomed !
This commit is contained in:
@ -1,26 +1,13 @@
|
|||||||
import prettier from '@prettier/sync';
|
import prettier from '@prettier/sync';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import glob from 'glob';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { Options } from 'prettier';
|
import { Options } from 'prettier';
|
||||||
import slash from 'slash';
|
import slash from 'slash';
|
||||||
|
import ts from 'typescript';
|
||||||
|
|
||||||
// TODO prastoin refactor this file in several one into its dedicated package and make it a TypeScript CLI
|
// TODO prastoin refactor this file in several one into its dedicated package and make it a TypeScript CLI
|
||||||
|
|
||||||
const INCLUDED_EXTENSIONS = ['.ts', '.tsx'];
|
|
||||||
const EXCLUDED_EXTENSIONS = [
|
|
||||||
'.test.ts',
|
|
||||||
'.test.tsx',
|
|
||||||
'.spec.ts',
|
|
||||||
'.spec.tsx',
|
|
||||||
'.stories.ts',
|
|
||||||
'.stories.tsx',
|
|
||||||
];
|
|
||||||
const EXCLUDED_DIRECTORIES = [
|
|
||||||
'__tests__',
|
|
||||||
'__mocks__',
|
|
||||||
'__stories__',
|
|
||||||
'internal',
|
|
||||||
];
|
|
||||||
const INDEX_FILENAME = 'index';
|
const INDEX_FILENAME = 'index';
|
||||||
const PACKAGE_JSON_FILENAME = 'package.json';
|
const PACKAGE_JSON_FILENAME = 'package.json';
|
||||||
const NX_PROJECT_CONFIGURATION_FILENAME = 'project.json';
|
const NX_PROJECT_CONFIGURATION_FILENAME = 'project.json';
|
||||||
@ -78,91 +65,77 @@ const getLastPathFolder = (path: string) => path.split('/').pop();
|
|||||||
const getSubDirectoryPaths = (directoryPath: string): string[] =>
|
const getSubDirectoryPaths = (directoryPath: string): string[] =>
|
||||||
fs
|
fs
|
||||||
.readdirSync(directoryPath)
|
.readdirSync(directoryPath)
|
||||||
.filter((fileOrDirectoryName) => {
|
.filter((fileOrDirectoryName) =>
|
||||||
const isDirectory = fs
|
fs.statSync(path.join(directoryPath, fileOrDirectoryName)).isDirectory(),
|
||||||
.statSync(path.join(directoryPath, fileOrDirectoryName))
|
)
|
||||||
.isDirectory();
|
|
||||||
if (!isDirectory) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isExcludedDirectory =
|
|
||||||
EXCLUDED_DIRECTORIES.includes(fileOrDirectoryName);
|
|
||||||
return !isExcludedDirectory;
|
|
||||||
})
|
|
||||||
.map((subDirectoryName) => path.join(directoryPath, subDirectoryName));
|
.map((subDirectoryName) => path.join(directoryPath, subDirectoryName));
|
||||||
|
|
||||||
const getDirectoryPathsRecursive = (directoryPath: string): string[] => [
|
const partitionFileExportsByType = (declarations: DeclarationOccurence[]) => {
|
||||||
directoryPath,
|
return declarations.reduce<{
|
||||||
...getSubDirectoryPaths(directoryPath).flatMap(getDirectoryPathsRecursive),
|
typeAndInterfaceDeclarations: DeclarationOccurence[];
|
||||||
];
|
otherDeclarations: DeclarationOccurence[];
|
||||||
|
}>(
|
||||||
|
(acc, { kind, name }) => {
|
||||||
|
if (kind === 'type' || kind === 'interface') {
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
typeAndInterfaceDeclarations: [
|
||||||
|
...acc.typeAndInterfaceDeclarations,
|
||||||
|
{ kind, name },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const getFilesPaths = (directoryPath: string): string[] =>
|
return {
|
||||||
fs.readdirSync(directoryPath).filter((filePath) => {
|
...acc,
|
||||||
const isFile = fs.statSync(path.join(directoryPath, filePath)).isFile();
|
otherDeclarations: [...acc.otherDeclarations, { kind, name }],
|
||||||
if (!isFile) {
|
};
|
||||||
return false;
|
},
|
||||||
}
|
{
|
||||||
|
typeAndInterfaceDeclarations: [],
|
||||||
const isIndexFile = filePath.startsWith(INDEX_FILENAME);
|
otherDeclarations: [],
|
||||||
if (isIndexFile) {
|
},
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isWhiteListedExtension = INCLUDED_EXTENSIONS.some((extension) =>
|
|
||||||
filePath.endsWith(extension),
|
|
||||||
);
|
|
||||||
const isExcludedExtension = EXCLUDED_EXTENSIONS.every(
|
|
||||||
(excludedExtension) => !filePath.endsWith(excludedExtension),
|
|
||||||
);
|
|
||||||
return isWhiteListedExtension && isExcludedExtension;
|
|
||||||
});
|
|
||||||
|
|
||||||
type ComputeExportLineForGivenFileArgs = {
|
|
||||||
filePath: string;
|
|
||||||
moduleDirectory: string; // Rename
|
|
||||||
directoryPath: string; // Rename
|
|
||||||
};
|
|
||||||
const computeExportLineForGivenFile = ({
|
|
||||||
filePath,
|
|
||||||
moduleDirectory,
|
|
||||||
directoryPath,
|
|
||||||
}: ComputeExportLineForGivenFileArgs) => {
|
|
||||||
const fileNameWithoutExtension = filePath.split('.').slice(0, -1).join('.');
|
|
||||||
const pathToImport = slash(
|
|
||||||
path.relative(
|
|
||||||
moduleDirectory,
|
|
||||||
path.join(directoryPath, fileNameWithoutExtension),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
// TODO refactor should extract all exports atomically please refer to https://github.com/twentyhq/core-team-issues/issues/644
|
|
||||||
return `export * from './${pathToImport}';`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateModuleIndexFiles = (moduleDirectories: string[]) => {
|
const generateModuleIndexFiles = (exportByBarrel: ExportByBarrel[]) => {
|
||||||
return moduleDirectories.map<createTypeScriptFileArgs>((moduleDirectory) => {
|
return exportByBarrel.map<createTypeScriptFileArgs>(
|
||||||
const directoryPaths = getDirectoryPathsRecursive(moduleDirectory);
|
({ barrel: { moduleDirectory }, allFileExports }) => {
|
||||||
const content = directoryPaths
|
const content = allFileExports
|
||||||
.flatMap((directoryPath) => {
|
.map(({ exports, file }) => {
|
||||||
const directFilesPaths = getFilesPaths(directoryPath);
|
const { otherDeclarations, typeAndInterfaceDeclarations } =
|
||||||
|
partitionFileExportsByType(exports);
|
||||||
|
|
||||||
return directFilesPaths.map((filePath) =>
|
const fileWithoutExtension = file.split('.').slice(0, -1).join('.');
|
||||||
computeExportLineForGivenFile({
|
const pathToImport = slash(
|
||||||
directoryPath,
|
path.relative(moduleDirectory, fileWithoutExtension),
|
||||||
filePath,
|
);
|
||||||
moduleDirectory: moduleDirectory,
|
const mapDeclarationNameAndJoin = (
|
||||||
}),
|
declarations: DeclarationOccurence[],
|
||||||
);
|
) => declarations.map(({ name }) => name).join(', ');
|
||||||
})
|
|
||||||
.sort((a, b) => a.localeCompare(b)) // Could be removed as using prettier afterwards anw ?
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
return {
|
const typeExport =
|
||||||
content,
|
typeAndInterfaceDeclarations.length > 0
|
||||||
path: moduleDirectory,
|
? `export type { ${mapDeclarationNameAndJoin(typeAndInterfaceDeclarations)} } from "./${pathToImport}"`
|
||||||
filename: INDEX_FILENAME,
|
: '';
|
||||||
};
|
const othersExport =
|
||||||
});
|
otherDeclarations.length > 0
|
||||||
|
? `export { ${mapDeclarationNameAndJoin(otherDeclarations)} } from "./${pathToImport}"`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return [typeExport, othersExport]
|
||||||
|
.filter((el) => el !== '')
|
||||||
|
.join('\n');
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
path: moduleDirectory,
|
||||||
|
filename: INDEX_FILENAME,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type JsonUpdate = Record<string, any>;
|
type JsonUpdate = Record<string, any>;
|
||||||
@ -201,7 +174,7 @@ const updateNxProjectConfigurationBuildOutputs = (outputs: JsonUpdate) => {
|
|||||||
...initialJsonFile.targets,
|
...initialJsonFile.targets,
|
||||||
build: {
|
build: {
|
||||||
...initialJsonFile.targets.build,
|
...initialJsonFile.targets.build,
|
||||||
outputs,
|
outputs,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -236,17 +209,200 @@ const computeProjectNxBuildOutputsPath = (moduleDirectories: string[]) => {
|
|||||||
return ['{projectRoot}/dist', ...dynamicOutputsPath];
|
return ['{projectRoot}/dist', ...dynamicOutputsPath];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EXCLUDED_EXTENSIONS = [
|
||||||
|
'**/*.test.ts',
|
||||||
|
'**/*.test.tsx',
|
||||||
|
'**/*.spec.ts',
|
||||||
|
'**/*.spec.tsx',
|
||||||
|
'**/*.stories.ts',
|
||||||
|
'**/*.stories.tsx',
|
||||||
|
];
|
||||||
|
const EXCLUDED_DIRECTORIES = [
|
||||||
|
'**/__tests__/**',
|
||||||
|
'**/__mocks__/**',
|
||||||
|
'**/__stories__/**',
|
||||||
|
'**/internal/**',
|
||||||
|
];
|
||||||
|
function getTypeScriptFiles(
|
||||||
|
directoryPath: string,
|
||||||
|
includeIndex: boolean = false,
|
||||||
|
): string[] {
|
||||||
|
const pattern = path.join(directoryPath, '**/*.{ts,tsx}');
|
||||||
|
const files = glob.sync(pattern, {
|
||||||
|
ignore: [...EXCLUDED_EXTENSIONS, ...EXCLUDED_DIRECTORIES],
|
||||||
|
});
|
||||||
|
|
||||||
|
return files.filter(
|
||||||
|
(file) =>
|
||||||
|
!file.endsWith('.d.ts') &&
|
||||||
|
(includeIndex ? true : !file.endsWith('index.ts')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getKind = (
|
||||||
|
node: ts.VariableStatement,
|
||||||
|
): Extract<ExportKind, 'const' | 'let' | 'var'> => {
|
||||||
|
const isConst = (node.declarationList.flags & ts.NodeFlags.Const) !== 0;
|
||||||
|
if (isConst) {
|
||||||
|
return 'const';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLet = (node.declarationList.flags & ts.NodeFlags.Let) !== 0;
|
||||||
|
if (isLet) {
|
||||||
|
return 'let';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'var';
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractExportsFromSourceFile(sourceFile: ts.SourceFile) {
|
||||||
|
const exports: DeclarationOccurence[] = [];
|
||||||
|
|
||||||
|
function visit(node: ts.Node) {
|
||||||
|
if (!ts.canHaveModifiers(node)) {
|
||||||
|
return ts.forEachChild(node, visit);
|
||||||
|
}
|
||||||
|
const modifiers = ts.getModifiers(node);
|
||||||
|
const isExport = modifiers?.some(
|
||||||
|
(mod) => mod.kind === ts.SyntaxKind.ExportKeyword,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isExport) {
|
||||||
|
return ts.forEachChild(node, visit);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (true) {
|
||||||
|
case ts.isTypeAliasDeclaration(node):
|
||||||
|
exports.push({
|
||||||
|
kind: 'type',
|
||||||
|
name: node.name.text,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ts.isInterfaceDeclaration(node):
|
||||||
|
exports.push({
|
||||||
|
kind: 'interface',
|
||||||
|
name: node.name.text,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ts.isEnumDeclaration(node):
|
||||||
|
exports.push({
|
||||||
|
kind: 'enum',
|
||||||
|
name: node.name.text,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ts.isFunctionDeclaration(node) && node.name !== undefined:
|
||||||
|
exports.push({
|
||||||
|
kind: 'function',
|
||||||
|
name: node.name.text,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ts.isVariableStatement(node):
|
||||||
|
node.declarationList.declarations.forEach((decl) => {
|
||||||
|
if (ts.isIdentifier(decl.name)) {
|
||||||
|
const kind = getKind(node);
|
||||||
|
exports.push({
|
||||||
|
kind,
|
||||||
|
name: decl.name.text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ts.isClassDeclaration(node) && node.name !== undefined:
|
||||||
|
exports.push({
|
||||||
|
kind: 'class',
|
||||||
|
name: node.name.text,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return ts.forEachChild(node, visit);
|
||||||
|
}
|
||||||
|
|
||||||
|
visit(sourceFile);
|
||||||
|
return exports;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExportKind =
|
||||||
|
| 'type'
|
||||||
|
| 'interface'
|
||||||
|
| 'enum'
|
||||||
|
| 'function'
|
||||||
|
| 'const'
|
||||||
|
| 'let'
|
||||||
|
| 'var'
|
||||||
|
| 'class';
|
||||||
|
type DeclarationOccurence = { kind: ExportKind; name: string };
|
||||||
|
type FileExports = Array<{
|
||||||
|
file: string;
|
||||||
|
exports: DeclarationOccurence[];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
function findAllExports(directoryPath: string): FileExports {
|
||||||
|
const results: FileExports = [];
|
||||||
|
|
||||||
|
const files = getTypeScriptFiles(directoryPath);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const sourceFile = ts.createSourceFile(
|
||||||
|
file,
|
||||||
|
fs.readFileSync(file, 'utf8'),
|
||||||
|
ts.ScriptTarget.Latest,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const exports = extractExportsFromSourceFile(sourceFile);
|
||||||
|
if (exports.length > 0) {
|
||||||
|
results.push({
|
||||||
|
file,
|
||||||
|
exports,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExportByBarrel = {
|
||||||
|
barrel: {
|
||||||
|
moduleName: string;
|
||||||
|
moduleDirectory: string;
|
||||||
|
};
|
||||||
|
allFileExports: FileExports;
|
||||||
|
};
|
||||||
|
const retrieveExportsByBarrel = (barrelDirectories: string[]) => {
|
||||||
|
return barrelDirectories.map<ExportByBarrel>((moduleDirectory) => {
|
||||||
|
const moduleExportsPerFile = findAllExports(moduleDirectory);
|
||||||
|
const moduleName = moduleDirectory.split('/').pop();
|
||||||
|
if (!moduleName) {
|
||||||
|
throw new Error(
|
||||||
|
`Should never occur moduleName not found ${moduleDirectory}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
barrel: {
|
||||||
|
moduleName,
|
||||||
|
moduleDirectory,
|
||||||
|
},
|
||||||
|
allFileExports: moduleExportsPerFile,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const main = () => {
|
const main = () => {
|
||||||
const moduleDirectories = getSubDirectoryPaths(SRC_PATH);
|
const moduleDirectories = getSubDirectoryPaths(SRC_PATH);
|
||||||
const moduleIndexFiles = generateModuleIndexFiles(moduleDirectories);
|
const exportsByBarrel = retrieveExportsByBarrel(moduleDirectories);
|
||||||
|
const moduleIndexFiles = generateModuleIndexFiles(exportsByBarrel);
|
||||||
const packageJsonPreconstructConfigAndFiles =
|
const packageJsonPreconstructConfigAndFiles =
|
||||||
computePackageJsonFilesAndPreconstructConfig(moduleDirectories);
|
computePackageJsonFilesAndPreconstructConfig(moduleDirectories);
|
||||||
const nxBuildOutputsPath =
|
const nxBuildOutputsPath =
|
||||||
computeProjectNxBuildOutputsPath(moduleDirectories);
|
computeProjectNxBuildOutputsPath(moduleDirectories);
|
||||||
|
|
||||||
updateNxProjectConfigurationBuildOutputs(
|
updateNxProjectConfigurationBuildOutputs(nxBuildOutputsPath);
|
||||||
nxBuildOutputsPath
|
|
||||||
);
|
|
||||||
writeInPackageJson(packageJsonPreconstructConfigAndFiles);
|
writeInPackageJson(packageJsonPreconstructConfigAndFiles);
|
||||||
moduleIndexFiles.forEach(createTypeScriptFile);
|
moduleIndexFiles.forEach(createTypeScriptFile);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,8 +7,8 @@
|
|||||||
* |___/
|
* |___/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './FieldForTotalCountAggregateOperation';
|
export { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from './FieldForTotalCountAggregateOperation';
|
||||||
export * from './PermissionsOnAllObjectRecords';
|
export { PermissionsOnAllObjectRecords } from './PermissionsOnAllObjectRecords';
|
||||||
export * from './StandardObjectRecordsUnderObjectRecordsPermissions';
|
export { STANDARD_OBJECT_RECORDS_UNDER_OBJECT_RECORDS_PERMISSIONS } from './StandardObjectRecordsUnderObjectRecordsPermissions';
|
||||||
export * from './TwentyCompaniesBaseUrl';
|
export { TWENTY_COMPANIES_BASE_URL } from './TwentyCompaniesBaseUrl';
|
||||||
export * from './TwentyIconsBaseUrl';
|
export { TWENTY_ICONS_BASE_URL } from './TwentyIconsBaseUrl';
|
||||||
|
|||||||
@ -7,4 +7,4 @@
|
|||||||
* |___/
|
* |___/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './types/EachTestingContext.type';
|
export type { EachTestingContext } from './types/EachTestingContext.type';
|
||||||
|
|||||||
@ -7,5 +7,5 @@
|
|||||||
* |___/
|
* |___/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './constants/AppLocales';
|
export { APP_LOCALES } from './constants/AppLocales';
|
||||||
export * from './constants/SourceLocale';
|
export { SOURCE_LOCALE } from './constants/SourceLocale';
|
||||||
|
|||||||
@ -7,6 +7,6 @@
|
|||||||
* |___/
|
* |___/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './ConnectedAccountProvider';
|
export { ConnectedAccountProvider } from './ConnectedAccountProvider';
|
||||||
export * from './FieldMetadataType';
|
export { FieldMetadataType } from './FieldMetadataType';
|
||||||
export * from './IsExactly';
|
export type { IsExactly } from './IsExactly';
|
||||||
|
|||||||
@ -7,16 +7,19 @@
|
|||||||
* |___/
|
* |___/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './assertUnreachable';
|
export { assertUnreachable } from './assertUnreachable';
|
||||||
export * from './fieldMetadata/isFieldMetadataDateKind';
|
export { isFieldMetadataDateKind } from './fieldMetadata/isFieldMetadataDateKind';
|
||||||
export * from './image/getImageAbsoluteURI';
|
export { getImageAbsoluteURI } from './image/getImageAbsoluteURI';
|
||||||
export * from './image/getLogoUrlFromDomainName';
|
export {
|
||||||
export * from './strings/capitalize';
|
sanitizeURL,
|
||||||
export * from './url/absoluteUrlSchema';
|
getLogoUrlFromDomainName,
|
||||||
export * from './url/getAbsoluteUrlOrThrow';
|
} from './image/getLogoUrlFromDomainName';
|
||||||
export * from './url/getUrlHostnameOrThrow';
|
export { capitalize } from './strings/capitalize';
|
||||||
export * from './url/isValidHostname';
|
export { absoluteUrlSchema } from './url/absoluteUrlSchema';
|
||||||
export * from './url/isValidUrl';
|
export { getAbsoluteUrlOrThrow } from './url/getAbsoluteUrlOrThrow';
|
||||||
export * from './validation/isDefined';
|
export { getUrlHostnameOrThrow } from './url/getUrlHostnameOrThrow';
|
||||||
export * from './validation/isValidLocale';
|
export { isValidHostname } from './url/isValidHostname';
|
||||||
export * from './validation/isValidUuid';
|
export { isValidUrl } from './url/isValidUrl';
|
||||||
|
export { isDefined } from './validation/isDefined';
|
||||||
|
export { isValidLocale } from './validation/isValidLocale';
|
||||||
|
export { isValidUuid } from './validation/isValidUuid';
|
||||||
|
|||||||
@ -7,5 +7,5 @@
|
|||||||
* |___/
|
* |___/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './types/WorkspaceActivationStatus';
|
export { WorkspaceActivationStatus } from './types/WorkspaceActivationStatus';
|
||||||
export * from './utils/isWorkspaceActiveOrSuspended';
|
export { isWorkspaceActiveOrSuspended } from './utils/isWorkspaceActiveOrSuspended';
|
||||||
|
|||||||
Reference in New Issue
Block a user