Add standard command menu items (#18527)
## Add standard command menu items
### Summary
This PR introduces standard command menu items, migrating hardcoded
command menu actions to the backend command menu item architecture
powered by front components. It adds a new `twenty-standard-application`
package that defines, builds, and registers front components as standard
command menu items, gated behind the `IS_COMMAND_MENU_ITEM_ENABLED`
feature flag.
### Description
- **New `twenty-standard-application` package**: Contains front
component definitions with an esbuild-based build pipeline that
generates minified `.mjs` bundles and a manifest with checksums.
- **Server-side registration**: New constants register all items with
metadata (labels, icons, positions, availability types, conditional
expressions). A `StandardFrontComponentUploadService` uploads built
components to file storage.
- **`FALLBACK` availability type**: New enum value for command menu
items that appear as fallback options (e.g., "Search Records" fallback).
- **`CommandMenuContextApi` refactor**
- **Conditional availability enhancements**: New array-based helper
functions for evaluating multi-record conditions.
- **Frontend wiring** (twenty-front):
`useCommandMenuItemFrontComponentCommands`
## Next steps
Only simple commands have been implemented for now:
- **Navigation (9)** -- `CommandLink`: go-to-companies,
go-to-dashboards, go-to-notes, go-to-opportunities, go-to-people,
go-to-runs, go-to-settings, go-to-tasks, go-to-workflows
- **Side panel (4)** -- `CommandOpenSidePanelPage`: ask-ai,
search-records, search-records-fallback, view-previous-ai-chats
We still have to implement front components for all the following
commands:
All have placeholder `execute` logic (`async () => {}`) with a `// TODO:
implement execute logic` comment:
**Record (22)**
- `add-to-favorites`, `remove-from-favorites`
- `create-new-record`, `create-new-view`
- `delete-single-record`, `delete-multiple-records`
- `destroy-single-record`, `destroy-multiple-records`
- `restore-single-record`, `restore-multiple-records`
- `export-from-record-index`, `export-from-record-show`,
`export-multiple-records`, `export-note-to-pdf`, `export-view`
- `hide-deleted-records`, `see-deleted-records`
- `import-records`, `merge-multiple-records`, `update-multiple-records`
- `navigate-to-next-record`, `navigate-to-previous-record`
**Page layout (3)** -- `cancel-record-page-layout`,
`edit-record-page-layout`, `save-record-page-layout`
**Dashboard (4)** -- `cancel-dashboard-layout`, `duplicate-dashboard`,
`edit-dashboard-layout`, `save-dashboard-layout`
**Workflow (10)** -- `activate-workflow`, `add-node-workflow`,
`deactivate-workflow`, `discard-draft-workflow`, `duplicate-workflow`,
`see-active-version-workflow`, `see-runs-workflow`,
`see-versions-workflow`, `test-workflow`, `tidy-up-workflow`
**Workflow version (4)** -- `see-runs-workflow-version`,
`see-versions-workflow-version`, `see-workflow-workflow-version`,
`use-as-draft-workflow-version`
**Workflow run (3)** -- `see-version-workflow-run`,
`see-workflow-workflow-run`, `stop-workflow-run`
This commit is contained in:
+385
@@ -0,0 +1,385 @@
|
||||
import {
|
||||
CommandMenuContextApiPageType,
|
||||
type CommandMenuContextApi,
|
||||
} from '@/types';
|
||||
import { evaluateConditionalAvailabilityExpression } from '../evaluateConditionalAvailabilityExpression';
|
||||
|
||||
const buildContext = (
|
||||
overrides: Partial<CommandMenuContextApi> = {},
|
||||
): CommandMenuContextApi => ({
|
||||
pageType: CommandMenuContextApiPageType.INDEX_PAGE,
|
||||
isInSidePanel: false,
|
||||
isPageInEditMode: false,
|
||||
favoriteRecordIds: [],
|
||||
isSelectAll: false,
|
||||
hasAnySoftDeleteFilterOnView: false,
|
||||
numberOfSelectedRecords: 0,
|
||||
objectPermissions: {
|
||||
objectMetadataId: '',
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: false,
|
||||
restrictedFields: {},
|
||||
rowLevelPermissionPredicates: [],
|
||||
rowLevelPermissionPredicateGroups: [],
|
||||
},
|
||||
selectedRecords: [],
|
||||
featureFlags: {},
|
||||
targetObjectReadPermissions: {},
|
||||
targetObjectWritePermissions: {},
|
||||
objectMetadataItem: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('evaluateConditionalAvailabilityExpression', () => {
|
||||
describe('arrayLength for favoriteRecordIds', () => {
|
||||
it('should evaluate arrayLength(favoriteRecordIds) < numberOfSelectedRecords when some selected are not favorites', () => {
|
||||
const expression =
|
||||
'arrayLength(favoriteRecordIds) < numberOfSelectedRecords and noneDefined(selectedRecords, "deletedAt") and not hasAnySoftDeleteFilterOnView';
|
||||
|
||||
const context = buildContext({
|
||||
favoriteRecordIds: ['id-1'],
|
||||
numberOfSelectedRecords: 2,
|
||||
selectedRecords: [
|
||||
{ id: 'id-1', createdAt: '', updatedAt: '', deletedAt: null },
|
||||
{ id: 'id-2', createdAt: '', updatedAt: '', deletedAt: null },
|
||||
],
|
||||
hasAnySoftDeleteFilterOnView: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(expression, context),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny when all selected are already favorites', () => {
|
||||
const expression =
|
||||
'arrayLength(favoriteRecordIds) < numberOfSelectedRecords and noneDefined(selectedRecords, "deletedAt") and not hasAnySoftDeleteFilterOnView';
|
||||
|
||||
const context = buildContext({
|
||||
favoriteRecordIds: ['id-1', 'id-2'],
|
||||
numberOfSelectedRecords: 2,
|
||||
selectedRecords: [
|
||||
{ id: 'id-1', createdAt: '', updatedAt: '', deletedAt: null },
|
||||
{ id: 'id-2', createdAt: '', updatedAt: '', deletedAt: null },
|
||||
],
|
||||
hasAnySoftDeleteFilterOnView: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(expression, context),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should evaluate arrayLength(favoriteRecordIds) == numberOfSelectedRecords when all selected are favorites', () => {
|
||||
const expression =
|
||||
'arrayLength(favoriteRecordIds) == numberOfSelectedRecords and noneDefined(selectedRecords, "deletedAt") and not hasAnySoftDeleteFilterOnView';
|
||||
|
||||
const context = buildContext({
|
||||
favoriteRecordIds: ['id-1', 'id-2'],
|
||||
numberOfSelectedRecords: 2,
|
||||
selectedRecords: [
|
||||
{ id: 'id-1', createdAt: '', updatedAt: '', deletedAt: null },
|
||||
{ id: 'id-2', createdAt: '', updatedAt: '', deletedAt: null },
|
||||
],
|
||||
hasAnySoftDeleteFilterOnView: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(expression, context),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject favoriteRecordIds.length (expr-eval parses "length" as unary op)', () => {
|
||||
const expression = 'favoriteRecordIds.length < numberOfSelectedRecords';
|
||||
const context = buildContext({
|
||||
favoriteRecordIds: ['id-1'],
|
||||
numberOfSelectedRecords: 2,
|
||||
});
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(expression, context),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('includesSome', () => {
|
||||
it('should return true when some records include the value in the array prop', () => {
|
||||
const expression = 'includesSome(selectedRecords, "statuses", "ACTIVE")';
|
||||
|
||||
const context = buildContext({
|
||||
selectedRecords: [
|
||||
{ id: 'id-1', statuses: ['ACTIVE'] },
|
||||
{ id: 'id-2', statuses: ['DRAFT'] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(expression, context),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when no record includes the value in the array prop', () => {
|
||||
const expression = 'includesSome(selectedRecords, "statuses", "ACTIVE")';
|
||||
|
||||
const context = buildContext({
|
||||
selectedRecords: [
|
||||
{ id: 'id-1', statuses: ['DRAFT'] },
|
||||
{ id: 'id-2', statuses: ['DRAFT'] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(expression, context),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('includesNone', () => {
|
||||
it('should return true when no record includes the value in the array prop', () => {
|
||||
const expression = 'includesNone(selectedRecords, "statuses", "ACTIVE")';
|
||||
|
||||
const context = buildContext({
|
||||
selectedRecords: [
|
||||
{ id: 'id-1', statuses: ['DRAFT'] },
|
||||
{ id: 'id-2', statuses: ['DRAFT'] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(expression, context),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when any record includes the value in the array prop', () => {
|
||||
const expression = 'includesNone(selectedRecords, "statuses", "ACTIVE")';
|
||||
|
||||
const context = buildContext({
|
||||
selectedRecords: [
|
||||
{ id: 'id-1', statuses: ['ACTIVE'] },
|
||||
{ id: 'id-2', statuses: ['DRAFT'] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(expression, context),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when all records include the value in the array prop', () => {
|
||||
const expression = 'includesNone(selectedRecords, "statuses", "ACTIVE")';
|
||||
|
||||
const context = buildContext({
|
||||
selectedRecords: [
|
||||
{ id: 'id-1', statuses: ['ACTIVE', 'DRAFT'] },
|
||||
{ id: 'id-2', statuses: ['ACTIVE'] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(expression, context),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty array guard against vacuous truth', () => {
|
||||
it('should return false for every() on empty selectedRecords', () => {
|
||||
const context = buildContext({ selectedRecords: [] });
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(
|
||||
'every(selectedRecords, "active")',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for everyDefined() on empty selectedRecords', () => {
|
||||
const context = buildContext({ selectedRecords: [] });
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(
|
||||
'everyDefined(selectedRecords, "name")',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for none() on empty selectedRecords', () => {
|
||||
const context = buildContext({ selectedRecords: [] });
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(
|
||||
'none(selectedRecords, "deletedAt")',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for noneDefined() on empty selectedRecords', () => {
|
||||
const context = buildContext({ selectedRecords: [] });
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(
|
||||
'noneDefined(selectedRecords, "deletedAt")',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for everyEquals() on empty selectedRecords', () => {
|
||||
const context = buildContext({ selectedRecords: [] });
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(
|
||||
'everyEquals(selectedRecords, "status", "DRAFT")',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for noneEquals() on empty selectedRecords', () => {
|
||||
const context = buildContext({ selectedRecords: [] });
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(
|
||||
'noneEquals(selectedRecords, "status", "ACTIVE")',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for includesEvery() on empty selectedRecords', () => {
|
||||
const context = buildContext({ selectedRecords: [] });
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(
|
||||
'includesEvery(selectedRecords, "statuses", "ACTIVE")',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for includesNone() on empty selectedRecords', () => {
|
||||
const context = buildContext({ selectedRecords: [] });
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(
|
||||
'includesNone(selectedRecords, "statuses", "ACTIVE")',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for some() on empty selectedRecords', () => {
|
||||
const context = buildContext({ selectedRecords: [] });
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(
|
||||
'some(selectedRecords, "active")',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for someDefined() on empty selectedRecords', () => {
|
||||
const context = buildContext({ selectedRecords: [] });
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(
|
||||
'someDefined(selectedRecords, "name")',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for includesSome() on empty selectedRecords', () => {
|
||||
const context = buildContext({ selectedRecords: [] });
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(
|
||||
'includesSome(selectedRecords, "statuses", "ACTIVE")',
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activateWorkflow expression regression', () => {
|
||||
const activateWorkflowExpression =
|
||||
'everyDefined(selectedRecords, "currentVersion.trigger") and everyDefined(selectedRecords, "currentVersion.steps") and every(selectedRecords, "currentVersion.steps.length") and (everyEquals(selectedRecords, "currentVersion.status", "DRAFT") or includesNone(selectedRecords, "statuses", "ACTIVE")) and noneDefined(selectedRecords, "deletedAt")';
|
||||
|
||||
it('should not show Activate for an already-active workflow', () => {
|
||||
const context = buildContext({
|
||||
selectedRecords: [
|
||||
{
|
||||
id: 'wf-1',
|
||||
deletedAt: null,
|
||||
statuses: ['ACTIVE'],
|
||||
currentVersion: {
|
||||
status: 'ACTIVE',
|
||||
trigger: { type: 'MANUAL' },
|
||||
steps: [{ id: 'step-1' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(
|
||||
activateWorkflowExpression,
|
||||
context,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should show Activate for a draft workflow with no active version', () => {
|
||||
const context = buildContext({
|
||||
selectedRecords: [
|
||||
{
|
||||
id: 'wf-1',
|
||||
deletedAt: null,
|
||||
statuses: ['DRAFT'],
|
||||
currentVersion: {
|
||||
status: 'DRAFT',
|
||||
trigger: { type: 'MANUAL' },
|
||||
steps: [{ id: 'step-1' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(
|
||||
activateWorkflowExpression,
|
||||
context,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should show Activate for a draft version when another version is active', () => {
|
||||
const context = buildContext({
|
||||
selectedRecords: [
|
||||
{
|
||||
id: 'wf-1',
|
||||
deletedAt: null,
|
||||
statuses: ['ACTIVE', 'DRAFT'],
|
||||
currentVersion: {
|
||||
status: 'DRAFT',
|
||||
trigger: { type: 'MANUAL' },
|
||||
steps: [{ id: 'step-1' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
evaluateConditionalAvailabilityExpression(
|
||||
activateWorkflowExpression,
|
||||
context,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
+118
-1
@@ -1,12 +1,129 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import {
|
||||
isNonEmptyArray,
|
||||
isNonEmptyString,
|
||||
isObject,
|
||||
isString,
|
||||
} from '@sniptt/guards';
|
||||
import { type EvaluationContext, Parser } from 'expr-eval';
|
||||
|
||||
import { isDefined } from '../validation/isDefined';
|
||||
|
||||
const BLOCKED_PROPERTY_NAMES = new Set([
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'prototype',
|
||||
]);
|
||||
|
||||
const safeGetNestedProperty = (
|
||||
objectToEvaluate: unknown,
|
||||
path: string,
|
||||
): unknown => {
|
||||
if (!isString(path)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parts = path.split('.');
|
||||
|
||||
let currentObject: unknown = objectToEvaluate;
|
||||
|
||||
for (const part of parts) {
|
||||
if (!isDefined(currentObject) || !isObject(currentObject)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (BLOCKED_PROPERTY_NAMES.has(part)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
currentObject = (currentObject as Record<string, unknown>)[part];
|
||||
}
|
||||
|
||||
return currentObject;
|
||||
};
|
||||
|
||||
type ArrayMethod = 'every' | 'some';
|
||||
|
||||
const createArrayPropCheck = (
|
||||
method: ArrayMethod,
|
||||
predicate: (value: unknown) => boolean,
|
||||
) => {
|
||||
return (array: unknown, prop: string) => {
|
||||
if (!isNonEmptyArray(array)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return array[method]((item) =>
|
||||
predicate(safeGetNestedProperty(item, prop)),
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const createArrayPropValueCheck = (
|
||||
method: ArrayMethod,
|
||||
predicate: (value: unknown, target: unknown) => boolean,
|
||||
) => {
|
||||
return (array: unknown, prop: string, value: unknown) => {
|
||||
if (!isNonEmptyArray(array)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return array[method]((item) =>
|
||||
predicate(safeGetNestedProperty(item, prop), value),
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const parser = new Parser();
|
||||
|
||||
parser.functions.isDefined = (value: unknown) => isDefined(value);
|
||||
parser.functions.isNonEmptyString = (value: unknown) => isNonEmptyString(value);
|
||||
parser.functions.includes = (array: unknown, value: unknown) =>
|
||||
Array.isArray(array) && array.includes(value);
|
||||
parser.functions.arrayLength = (value: unknown) =>
|
||||
Array.isArray(value) ? value.length : 0;
|
||||
|
||||
parser.functions.every = createArrayPropCheck('every', Boolean);
|
||||
parser.functions.everyDefined = createArrayPropCheck('every', isDefined);
|
||||
parser.functions.some = createArrayPropCheck('some', Boolean);
|
||||
parser.functions.someDefined = createArrayPropCheck('some', isDefined);
|
||||
parser.functions.someNonEmptyString = createArrayPropCheck(
|
||||
'some',
|
||||
isNonEmptyString,
|
||||
);
|
||||
parser.functions.none = createArrayPropCheck(
|
||||
'every',
|
||||
(value) => !Boolean(value),
|
||||
);
|
||||
parser.functions.noneDefined = createArrayPropCheck(
|
||||
'every',
|
||||
(value) => !isDefined(value),
|
||||
);
|
||||
|
||||
parser.functions.everyEquals = createArrayPropValueCheck(
|
||||
'every',
|
||||
(a, b) => a === b,
|
||||
);
|
||||
parser.functions.someEquals = createArrayPropValueCheck(
|
||||
'some',
|
||||
(a, b) => a === b,
|
||||
);
|
||||
parser.functions.noneEquals = createArrayPropValueCheck(
|
||||
'every',
|
||||
(a, b) => a !== b,
|
||||
);
|
||||
|
||||
parser.functions.includesEvery = createArrayPropValueCheck(
|
||||
'every',
|
||||
(array, value) => Array.isArray(array) && array.includes(value),
|
||||
);
|
||||
parser.functions.includesSome = createArrayPropValueCheck(
|
||||
'some',
|
||||
(array, value) => Array.isArray(array) && array.includes(value),
|
||||
);
|
||||
parser.functions.includesNone = createArrayPropValueCheck(
|
||||
'every',
|
||||
(array, value) => Array.isArray(array) && !array.includes(value),
|
||||
);
|
||||
|
||||
export const evaluateConditionalAvailabilityExpression = (
|
||||
expression: string | null | undefined,
|
||||
|
||||
@@ -152,6 +152,7 @@ export { appendCopySuffix } from './strings/appendCopySuffix';
|
||||
export { camelToKebab } from './strings/camelToKebab';
|
||||
export { camelToSnakeCase } from './strings/camelToSnakeCase';
|
||||
export { capitalize } from './strings/capitalize';
|
||||
export { kebabToCamelCase } from './strings/kebabToCamelCase';
|
||||
export { pascalCase } from './strings/pascalCase';
|
||||
export { pascalToKebab } from './strings/pascalToKebab';
|
||||
export { stringifySafely } from './strings/stringifySafely';
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { kebabToCamelCase } from '../kebabToCamelCase';
|
||||
|
||||
describe('kebabToCamelCase', () => {
|
||||
it('should convert kebab-case to camelCase', () => {
|
||||
expect(kebabToCamelCase('user-profile')).toBe('userProfile');
|
||||
});
|
||||
|
||||
it('should convert single word', () => {
|
||||
expect(kebabToCamelCase('button')).toBe('button');
|
||||
});
|
||||
|
||||
it('should handle multi-word kebab-case', () => {
|
||||
expect(kebabToCamelCase('my-component-name')).toBe('myComponentName');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(kebabToCamelCase('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './appendCopySuffix';
|
||||
export * from './capitalize';
|
||||
export * from './kebabToCamelCase';
|
||||
export * from './pascalCase';
|
||||
export * from './pascalToKebab';
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const kebabToCamelCase = (str: string): string =>
|
||||
str.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
|
||||
Reference in New Issue
Block a user