Skip to content

Commit

Permalink
feat!: move from isWarning and isError to status prop (#1973)
Browse files Browse the repository at this point in the history
- update all (sub-)components to use status instead of two sep. props
- refactor stories to use the new prop
- ensure snapshots do not change (not updating class names at the
  moment)
- add in migration codemod for changes since last alpha release
  • Loading branch information
booc0mtaco authored Jun 5, 2024
1 parent 76479c1 commit 56066ae
Show file tree
Hide file tree
Showing 12 changed files with 439 additions and 163 deletions.
156 changes: 156 additions & 0 deletions src/bin/migrate/migrations/alpha14-to-alpha15.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { dedent } from 'ts-dedent';

import { updateStatusProp } from './alpha14-to-alpha15';
import { createTestSourceFile } from '../helpers';

describe('alpha14-to-alpha15', () => {
it('updates an appropriate isWarning prop', () => {
const sourceFileText = dedent`
import {FieldNote} from '@chanzuckerberg/eds';
export default function Component() {
return (
<FieldNote isWarning />
)
}
`;

const sourceFile = createTestSourceFile(sourceFileText);

updateStatusProp({
file: sourceFile,
});

expect(sourceFile.getText()).toEqual(dedent`
import {FieldNote} from '@chanzuckerberg/eds';
export default function Component() {
return (
<FieldNote status="warning" />
)
}
`);
});

it('updates an appropriate isError prop', () => {
const sourceFileText = dedent`
import {FieldNote} from '@chanzuckerberg/eds';
export default function Component() {
return (
<FieldNote isError />
)
}
`;

const sourceFile = createTestSourceFile(sourceFileText);

updateStatusProp({
file: sourceFile,
});

expect(sourceFile.getText()).toEqual(dedent`
import {FieldNote} from '@chanzuckerberg/eds';
export default function Component() {
return (
<FieldNote status="critical" />
)
}
`);
});

it('does not update an isError prop on non-EDS component', () => {
const sourceFileText = dedent`
import {FieldNote} from 'somewhere';
export default function Component() {
return (
<FieldNote isError />
)
}
`;

const sourceFile = createTestSourceFile(sourceFileText);

updateStatusProp({
file: sourceFile,
});

expect(sourceFile.getText()).toEqual(dedent`
import {FieldNote} from 'somewhere';
export default function Component() {
return (
<FieldNote isError />
)
}
`);
});

it('updates appropriately when both isError and isWarning prop exists', () => {
const sourceFileText = dedent`
import {FieldNote} from '@chanzuckerberg/eds';
export default function Component() {
return (
<FieldNote isWarning isError />
)
}
`;

const sourceFile = createTestSourceFile(sourceFileText);

updateStatusProp({
file: sourceFile,
});

expect(sourceFile.getText()).toEqual(dedent`
import {FieldNote} from '@chanzuckerberg/eds';
export default function Component() {
return (
<FieldNote status="warning" status="critical" />
)
}
`);
});

it('converts on all component types', () => {
const sourceFileText = dedent`
import {FieldNote, InputField, Select, TextareaField} from '@chanzuckerberg/eds';
export default function Component() {
return (
<div>
<InputField isWarning />
<FieldNote isWarning></FieldNote>
<Select isError></Select>
<TextareaField isError isWarning></Textarea>
</div>
);
}
`;

const sourceFile = createTestSourceFile(sourceFileText);

updateStatusProp({
file: sourceFile,
});

expect(sourceFile.getText()).toEqual(dedent`
import {FieldNote, InputField, Select, TextareaField} from '@chanzuckerberg/eds';
export default function Component() {
return (
<div>
<InputField status="warning" />
<FieldNote status="warning"></FieldNote>
<Select status="critical"></Select>
<TextareaField status="warning" status="critical"></Textarea>
</div>
);
}
`);
});
});
98 changes: 98 additions & 0 deletions src/bin/migrate/migrations/alpha14-to-alpha15.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {
type JsxAttribute,
SyntaxKind,
type Project,
type SourceFile,
} from 'ts-morph';
import { isDesignSystemImport } from '../helpers';

export default function migration(project: Project) {
const files = project.getSourceFiles();
const sourceFiles = files.filter((file) => !file.isDeclarationFile());

sourceFiles.forEach((sourceFile) => {
updateStatusProp({ file: sourceFile }); // https://github.com/chanzuckerberg/edu-design-system/pull/1973
});
}

type TransformOptions = {
file: SourceFile;
};

const statusComponents = [
'FieldNote',
'InputField',
'Select',
'TextareaField',
].map((name) => name.toLowerCase());

export function updateStatusProp({ file }: TransformOptions) {
// Filter down to the design-system-only imports
const importDeclarations = file
.getImportDeclarations()
.filter(
(importDeclaration) =>
!importDeclaration.isTypeOnly() &&
isDesignSystemImport(importDeclaration),
);

const jsxElements = file.getDescendantsOfKind(SyntaxKind.JsxOpeningElement);
const jsxSelfClosingElements = file.getDescendantsOfKind(
SyntaxKind.JsxSelfClosingElement,
);

// Get the component usages in the given file (which should only work on EDS imports)
[...jsxElements, ...jsxSelfClosingElements].forEach((element) => {
const tagName = element.getTagNameNode().getText();
const edsTags: string[] = [];
importDeclarations.forEach((importDeclaration) => {
importDeclaration.getNamedImports().forEach((namedImport) => {
edsTags.push(namedImport.getName());
});
});

if (
statusComponents.includes(tagName.toLowerCase()) &&
edsTags.includes(tagName)
) {
// detect if isWarning exists (at all or with value true)
if (
isBooleanTrue(
element.getAttribute('isWarning')?.asKind(SyntaxKind.JsxAttribute),
)
) {
element.getAttribute('isWarning')?.remove();
element.addAttribute({
name: 'status',
initializer: `"warning"`,
});
}

// detect if isError exists (at all or with value true)
if (
isBooleanTrue(
element.getAttribute('isError')?.asKind(SyntaxKind.JsxAttribute),
)
) {
element.getAttribute('isError')?.remove();
element.addAttribute({
name: 'status',
initializer: `"critical"`,
});
}
}
});
}

/**
* Determine whether the attribute evaluates to being set to true
*
* @param attribute the attribute retrieved from the element node
* @returns whether the elements attribute evaluates to true in JSX (exists or exists AND is true)
*/
function isBooleanTrue(attribute: JsxAttribute | undefined): boolean {
return (
(attribute && typeof attribute?.getInitializer() === 'undefined') ||
attribute?.getInitializer()?.getText() === '{true}'
);
}
5 changes: 3 additions & 2 deletions src/components/FieldNote/FieldNote-v2.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,16 @@ export const WithErrorIcon: StoryObj<Args> = {
children: 'This is a fieldnote.',
id: 'field-1',
icon: 'warning-filled',
isError: true,
status: 'critical',
},
};

export const WithIcon: StoryObj<Args> = {
export const WithWarningIcon: StoryObj<Args> = {
args: {
children: 'This is a fieldnote.',
id: 'field-1',
icon: 'warning-filled',
status: 'warning',
},
};

Expand Down
31 changes: 15 additions & 16 deletions src/components/FieldNote/FieldNote-v2.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import clsx from 'clsx';
import type { ReactNode } from 'react';
import React from 'react';
import type { Status } from '../../util/variant-types';
import { IconV2 as Icon, type IconNameV2 as IconName } from '../Icon';
import styles from './FieldNote-v2.module.css';

Expand Down Expand Up @@ -30,13 +31,11 @@ export interface Props {
*/
icon?: Extract<IconName, 'dangerous' | 'warning-filled'>;
/**
* Whether there is an error state for the field note text (and icon)
*/
isError?: boolean;
/**
* Whether there is a warning state for the field note text (and icon)
* Status for the field state
*
* **Default is `"default"`**.
*/
isWarning?: boolean;
status?: 'default' | Extract<Status, 'warning' | 'critical'>;
}

/**
Expand All @@ -50,36 +49,36 @@ export const FieldNote = ({
id,
disabled,
icon,
isError,
isWarning,
status,
...other
}: Props) => {
const componentClassName = clsx(
styles['field-note'],
disabled && styles['field-note--disabled'],
isError && styles['field-note--error'],
isWarning && styles['field-note--warning'],
status === 'critical' && styles['field-note--error'],
status === 'warning' && styles['field-note--warning'],
className,
);

let iconToUse = icon;
if (isError) {
let title = 'fieldnote status icon';
if (status === 'critical') {
iconToUse = 'dangerous';
} else if (isWarning) {
title = 'error';
} else if (status === 'warning') {
iconToUse = 'warning-filled';
} else if (icon) {
iconToUse = icon;
title = 'warning';
}

return (
<div className={componentClassName} id={id} {...other}>
{(isError || isWarning || iconToUse) && (
{(status === 'critical' || status === 'warning' || iconToUse) && (
<Icon
className={styles['field-note__icon']}
name={iconToUse}
purpose="informative"
size="1rem"
title={isError ? 'error' : 'warning'}
title={title}
/>
)}
{children}
Expand Down
17 changes: 8 additions & 9 deletions src/components/Input/Input-v2.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import clsx from 'clsx';
import type { ChangeEventHandler } from 'react';
import React, { forwardRef } from 'react';
import type { Status } from '../../util/variant-types';
import styles from './Input-v2.module.css';

export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
Expand Down Expand Up @@ -78,24 +79,22 @@ export type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
defaultValue?: string | number;
// Design API
/**
* Error state of the form field
* Status for the field state
*
* **Default is `"default"`**.
*/
isError?: boolean;
/**
* Whether there is a warning state for the field note text (and icon)
*/
isWarning?: boolean;
status?: 'default' | Extract<Status, 'warning' | 'critical'>;
};

/**
* Input component for one line of text.
*/
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, disabled, id, isError, isWarning, ...other }, ref) => {
({ className, disabled, id, status, ...other }, ref) => {
const componentClassName = clsx(
styles['input'],
isError && styles['error'],
isWarning && styles['warning'],
status === 'critical' && styles['error'],
status === 'warning' && styles['warning'],
className,
);

Expand Down
Loading

0 comments on commit 56066ae

Please sign in to comment.