Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,26 @@ WordPress Feature flags plugin allow developers to configure features in plugins

## Hooks

### JS Filters
### PHP filters

##### mrFeatureFlags.newFlag.defaultStatus
#### `mr_feature_flags_max_allowed`

Filter to define the maximum number of allowed flags. It is recommended to keep this to default value, which is 20.

Example usage:

```php
add_filter(
'mr_feature_flags_max_allowed',
static function () {
return 10;
}
);
```

### JS filters

##### `mrFeatureFlags.newFlag.defaultStatus`

The filter controls whether the new flag is enabled by default or not. Default `true`

Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ services:
depends_on:
- mysql
mysql:
image: mysql:8
image: mysql:8.0
env_file: .env
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
Expand Down
19 changes: 18 additions & 1 deletion includes/Api/Flags.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ class Flags {
*/
public static $option_name = 'mr_feature_flags';

/**
* Maximum allowed flags.
*
* @var int $max_flags
*/
private static $max_flags = 20;

/**
* Register feature flag endpoints.
*
Expand Down Expand Up @@ -92,6 +99,16 @@ public function post_flags( WP_REST_Request $request ) {
$input_data = $request->get_json_params();

if ( is_array( $input_data['flags'] ) ) {
/**
* Filter to update max allowed feature flags.
*/
$max_allowed_flags = apply_filters( 'mr_feature_flags_max_allowed', self::$max_flags );
if ( count( $input_data['flags'] ) > $max_allowed_flags ) {
// translators: %d is a placeholder for the maximum allowed flags.
$error_message = sprintf( __( 'Cannot add more than %d flags', 'mr-feature-flags' ), $max_allowed_flags );
return new WP_Error( 'flag_limit_exceeded', $error_message, array( 'status' => 400 ) );
}

update_option( self::$option_name, $input_data['flags'] );
return rest_ensure_response(
array(
Expand All @@ -101,7 +118,7 @@ public function post_flags( WP_REST_Request $request ) {
);
}

return new WP_Error( 'invalid_input', 'Cannot update flags', array( 'status' => 400 ) );
return new WP_Error( 'invalid_input', __( 'Cannot update flags', 'mr-feature-flags' ), array( 'status' => 400 ) );
}

/**
Expand Down
1 change: 1 addition & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default defineConfig({
use: {
baseURL: process.env.WP_BASE_URL,
trace: 'on-first-retry',
permissions: ['clipboard-read'],
},

projects: [
Expand Down
39 changes: 29 additions & 10 deletions src/components/Flags.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ import SubmitControls from './SubmitControls';
import { getFlags, updateFlags } from '../utils';
import Header from './Header';
import { __ } from '@wordpress/i18n';
import { dispatch } from '@wordpress/data';
import { useDispatch } from '@wordpress/data';

const Layout = (): JSX.Element => {
const [flags, setFlags] = useState<Flag[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [disableSave, setDisableSave] = useState<boolean>(false);

const { createErrorNotice, createSuccessNotice } =
useDispatch('core/notices');

useEffect(() => {
const logFlags = async () => {
const fetchedFlags = await getFlags();
Expand All @@ -26,15 +29,31 @@ const Layout = (): JSX.Element => {
logFlags();
}, [setFlags, setIsLoading]);

const remoteApi = useCallback(async (input: Flag[]) => {
await updateFlags({ ...input });
//@ts-ignore
dispatch('core/notices').createSuccessNotice('Saved successfully!', {
type: 'snackbar',
id: 'mr-feature-flags-snackbar',
icon: <>✅</>,
});
}, []);
const remoteApi = useCallback(
async (input: Flag[]) => {
try {
const response = await updateFlags({ ...input });
if ('status' in response && response.status === 200) {
createSuccessNotice(
__('Saved successfully!', 'mr-feature-flags'),
{
type: 'snackbar',
id: 'mr-feature-flags-snackbar',
icon: <>✅</>,
}
);
}
} catch (error: unknown) {
//@ts-ignore
createErrorNotice(error?.message, {
type: 'snackbar',
id: 'mr-feature-flags-snackbar',
icon: <>❌</>,
});
}
},
[createErrorNotice, createSuccessNotice]
);

const lastFlag = flags?.at(-1)?.id || 0;

Expand Down
9 changes: 7 additions & 2 deletions src/styles/settings.scss
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@
bottom: 3.5rem;
position: fixed;

div {
float: right;
right: 250px;
}

.components-snackbar__icon {
left: 18px;
top: auto;
left: 18px;
}
}

Expand Down Expand Up @@ -55,7 +60,7 @@
.mr-feature-flags-toggle {
margin-top: 7px;
margin-left: 40px;
min-width: 150px;
min-width: 130px !important;
}

.mr-feature-flags-sdk {
Expand Down
34 changes: 25 additions & 9 deletions tests/e2e/feature-flags.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ERROR_FLAG_EXISTS, ERROR_FLAG_INVALID } from '../../src/constants';
test.use({ storageState: process.env.WP_AUTH_STORAGE });

test.describe('Feature flags', () => {
test('Create and delete flags e2e scenarios', async ({ page, admin }) => {
test.beforeEach(async ({ page, admin }) => {
await admin.visitAdminPage('/');

//Find the feature flags in side menu
Expand All @@ -15,18 +15,19 @@ test.describe('Feature flags', () => {
await expect(
page.getByRole('heading', { name: 'Feature Flags settings' })
).toBeVisible();
});

test('Create and delete flags e2e scenarios', async ({ page }) => {
//Create new flag
await page.getByRole('button', { name: 'Add Flag' }).click();
// await expect(await page.getByRole('textbox').count()).toBe(4);
await page.getByRole('textbox').last().fill('test');
await page.getByRole('button', { name: 'Save' }).click();
//Confirm save success
expect(
await page.getByLabel('Dismiss this notice').innerText()
).toMatch(/Saved successfully!/);

//Toggle flag test
//Toggle feature flag
await page
.locator('id=mr-feature-flag-item')
.last()
Expand All @@ -45,23 +46,21 @@ test.describe('Feature flags', () => {
//Create another flag with same name
await page.getByRole('button', { name: 'Add Flag' }).click();
await page.getByRole('textbox').last().fill('test');
expect(await page.getByText(ERROR_FLAG_EXISTS)).toBeVisible();
expect(page.getByText(ERROR_FLAG_EXISTS)).toBeVisible();
expect(page.getByRole('button', { name: 'Save' })).toBeDisabled();

//update flag name to be unique and check text validation.
await page.getByRole('textbox').last().fill('test 2');
expect(await page.getByText(ERROR_FLAG_INVALID)).toBeVisible();

expect(page.getByText(ERROR_FLAG_INVALID)).toBeVisible();
expect(page.getByRole('button', { name: 'Save' })).toBeDisabled();

//Delete the flag
await page
.locator('id=mr-feature-flag-item')
.last()
.getByLabel('Delete Flag')
.click();

await page.getByRole('button', { name: 'Yes' }).click();

//Confirm delete success
expect(
await page.getByLabel('Dismiss this notice').innerText()
Expand All @@ -72,14 +71,31 @@ test.describe('Feature flags', () => {
expect(
page.getByRole('heading', { name: 'SDK for feature flag: test' })
).toBeVisible();

// Check PHP Snippet clipboard details
await page.getByLabel('Copy to clipboard').first().click();
const phpClipboardText = await page.evaluate(
'navigator.clipboard.readText()'
);
expect(phpClipboardText).toContain("Flag::is_enabled( 'test' )");

// Check JS Snippet clipboard details
await page.getByLabel('Copy to clipboard').nth(1).click();
const jsClipboardText: string = await page.evaluate(
'navigator.clipboard.readText()'
);
expect(jsClipboardText).toContain(
"window.mrFeatureFlags.isEnabled('test')"
);
//Close SDK modal
await page.locator('button[aria-label="Close"]').click();

//Delete the created flag
await page
.locator('id=mr-feature-flag-item')
.last()
.getByLabel('Delete Flag')
.click();

await page.getByRole('button', { name: 'Yes' }).click();
});
});
57 changes: 57 additions & 0 deletions tests/integration/FlagsApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,63 @@ public function test_create_item_with_invalid_input_array() {

}

public function test_create_item_with_default_max_allowed_filter() {
wp_set_current_user( self::$admin );
$flags = [['id'=>1, 'name'=>'test', 'enabled'=>true],
['id'=>2, 'name'=>'test2', 'enabled'=>false],
['id'=>3, 'name'=>'test3', 'enabled'=>false],
['id'=>4, 'name'=>'test4', 'enabled'=>false],
['id'=>5, 'name'=>'test5', 'enabled'=>false],
['id'=>6, 'name'=>'test6', 'enabled'=>false],
['id' => 7, 'name' => 'test7', 'enabled' => true],
['id' => 8, 'name' => 'test8', 'enabled' => false],
['id' => 9, 'name' => 'test9', 'enabled' => false],
['id' => 10, 'name' => 'test10', 'enabled' => false],
['id' => 11, 'name' => 'test11', 'enabled' => false],
['id' => 12, 'name' => 'test12', 'enabled' => false],
['id' => 13, 'name' => 'test13', 'enabled' => false],
['id' => 14, 'name' => 'test14', 'enabled' => false],
['id' => 15, 'name' => 'test15', 'enabled' => false],
['id' => 16, 'name' => 'test16', 'enabled' => false],
['id' => 17, 'name' => 'test17', 'enabled' => false],
['id' => 18, 'name' => 'test18', 'enabled' => false],
['id' => 19, 'name' => 'test19', 'enabled' => false],
['id' => 20, 'name' => 'test20', 'enabled' => false],
['id' => 21, 'name' => 'test21', 'enabled' => false],];

$request = new WP_REST_Request( 'POST', self::$api_endpoint );
$request->add_header( 'Content-Type', 'application/json' );
$request->set_body( wp_json_encode( ['flags' => $flags] ) );
$response = rest_get_server()->dispatch( $request );
$response_message = $response->get_data()['message'];

$this->assertErrorResponse( 'flag_limit_exceeded', $response, 400 );
$this->assertEquals('Maximum allowed flags are 20', $response_message);

}

public function test_create_item_with_custom_max_allowed_filter() {
wp_set_current_user( self::$admin );

// Mock the filter hook
$mocked_max_flags = 3;
add_filter('mr_feature_flags_max_allowed', function () use ($mocked_max_flags) {
return $mocked_max_flags;
});

$flags = [['id'=>1, 'name'=>'test', 'enabled'=>true], ['id'=>2, 'name'=>'test2', 'enabled'=>false], ['id'=>3, 'name'=>'test2', 'enabled'=>false], ['id'=>4, 'name'=>'test2', 'enabled'=>false]];

$request = new WP_REST_Request( 'POST', self::$api_endpoint );
$request->add_header( 'Content-Type', 'application/json' );
$request->set_body( wp_json_encode( ['flags' => $flags] ) );
$response = rest_get_server()->dispatch( $request );
$response_message = $response->get_data()['message'];

$this->assertErrorResponse( 'flag_limit_exceeded', $response, 400 );
$this->assertEquals('Maximum allowed flags are 3', $response_message);

}

public function test_create_item_without_input() {
wp_set_current_user( self::$admin );

Expand Down