From 79d3dd78881c8922126f52e8b309425c980f6d3e Mon Sep 17 00:00:00 2001 From: Julien Nahum Date: Tue, 27 Aug 2024 16:49:43 +0200 Subject: [PATCH] Feature flags (#543) * Re-organize a bit controllers * Added the featureflagcontroller * Implement feature flags in the front-end * Clean env files * Clean console.log messages * Fix feature flag test --- api/.env.docker | 5 +- api/.env.example | 4 ++ .../{ => Auth}/UserInviteController.php | 7 +- .../Content/FeatureFlagsController.php | 41 +++++++++++ .../{ => Content}/FontsController.php | 7 +- .../{ => Content}/SitemapController.php | 5 +- .../{ => Forms}/TemplateController.php | 5 +- api/config/services.php | 4 ++ api/routes/api.php | 9 +-- .../Feature/FeatureFlagsControllerTest.php | 69 +++++++++++++++++++ client/.env.docker | 6 +- client/.env.example | 4 -- client/components/forms/DateInput.vue | 6 +- client/components/global/Navbar.vue | 10 +-- client/components/global/ProTag.vue | 2 +- .../components/FormEditorErrorHandler.vue | 53 ++++++++------ .../form-components/FormCustomSeo.vue | 5 +- .../form-components/FormCustomization.vue | 39 ++++++----- client/components/pages/OpenFormFooter.vue | 28 ++++---- .../pages/auth/components/LoginForm.vue | 3 +- .../pages/auth/components/RegisterForm.vue | 28 ++++---- .../forms/create/CreateFormBaseModal.vue | 8 +-- .../pages/settings/WorkSpaceCustomDomains.vue | 6 +- .../pages/settings/WorkSpaceUser.vue | 2 +- client/composables/useFeatureFlag.js | 5 ++ client/lib/file-uploads.js | 2 +- client/lib/forms/public-page.js | 1 - client/lib/utils.js | 2 +- client/middleware/custom-domain.global.js | 2 +- client/middleware/feature-flags.global.js | 9 +++ client/middleware/self-hosted-credentials.js | 2 +- client/middleware/self-hosted.js | 5 +- client/pages/index.vue | 5 +- client/pages/register.vue | 2 +- client/pages/settings/workspace.vue | 5 +- client/plugins/featureFlags.js | 9 +++ client/runtimeConfig.js | 6 +- client/stores/app.js | 3 - client/stores/featureFlags.js | 24 +++++++ client/stores/form_integrations.js | 13 ++-- 40 files changed, 304 insertions(+), 147 deletions(-) rename api/app/Http/Controllers/{ => Auth}/UserInviteController.php (89%) create mode 100644 api/app/Http/Controllers/Content/FeatureFlagsController.php rename api/app/Http/Controllers/{ => Content}/FontsController.php (81%) rename api/app/Http/Controllers/{ => Content}/SitemapController.php (79%) rename api/app/Http/Controllers/{ => Forms}/TemplateController.php (94%) create mode 100644 api/tests/Feature/FeatureFlagsControllerTest.php create mode 100644 client/composables/useFeatureFlag.js create mode 100644 client/middleware/feature-flags.global.js create mode 100644 client/plugins/featureFlags.js create mode 100644 client/stores/featureFlags.js diff --git a/api/.env.docker b/api/.env.docker index 6d2e17c4f..bd080361d 100644 --- a/api/.env.docker +++ b/api/.env.docker @@ -4,6 +4,8 @@ APP_KEY= APP_DEBUG=false APP_URL=http://localhost +SELF_HOSTED=true + LOG_CHANNEL=errorlog LOG_LEVEL=debug @@ -43,5 +45,4 @@ JWT_SECRET= MUX_WORKSPACE_ID= MUX_API_TOKEN= -OPEN_AI_API_KEY= -SELF_HOSTED=true \ No newline at end of file +OPEN_AI_API_KEY= \ No newline at end of file diff --git a/api/.env.example b/api/.env.example index 8907431a7..31762cf80 100644 --- a/api/.env.example +++ b/api/.env.example @@ -5,6 +5,8 @@ APP_DEBUG=true APP_LOG_LEVEL=debug APP_URL=http://localhost +SELF_HOSTED=true + LOG_CHANNEL=stack LOG_LEVEL=debug @@ -87,3 +89,5 @@ GOOGLE_REDIRECT_URL=http://localhost:3000/settings/connections/callback/google GOOGLE_AUTH_REDIRECT_URL=http://localhost:3000/oauth/google/callback GOOGLE_FONTS_API_KEY= + +ZAPIER_ENABLED=false diff --git a/api/app/Http/Controllers/UserInviteController.php b/api/app/Http/Controllers/Auth/UserInviteController.php similarity index 89% rename from api/app/Http/Controllers/UserInviteController.php rename to api/app/Http/Controllers/Auth/UserInviteController.php index cb5f14788..81f600465 100644 --- a/api/app/Http/Controllers/UserInviteController.php +++ b/api/app/Http/Controllers/Auth/UserInviteController.php @@ -1,11 +1,12 @@ error(['success' => false, 'message' => 'Invite not found for this workspace.']); } - if($userInvite->status == UserInvite::ACCEPTED_STATUS) { + if ($userInvite->status == UserInvite::ACCEPTED_STATUS) { return $this->error(['success' => false, 'message' => 'Invite already accepted.']); } @@ -49,7 +50,7 @@ public function cancelInvite($workspaceId, $inviteId) return $this->error(['success' => false, 'message' => 'Invite not found for this workspace.']); } - if($userInvite->status == UserInvite::ACCEPTED_STATUS) { + if ($userInvite->status == UserInvite::ACCEPTED_STATUS) { return $this->error(['success' => false, 'message' => 'Invite already accepted.']); } diff --git a/api/app/Http/Controllers/Content/FeatureFlagsController.php b/api/app/Http/Controllers/Content/FeatureFlagsController.php new file mode 100644 index 000000000..8ff99de4c --- /dev/null +++ b/api/app/Http/Controllers/Content/FeatureFlagsController.php @@ -0,0 +1,41 @@ + config('app.self_hosted', true), + 'custom_domains' => config('custom_domains.enabled', false), + 'ai_features' => !empty(config('services.openai.api_key')), + + 'billing' => [ + 'enabled' => !empty(config('cashier.key')) && !empty(config('cashier.secret')), + 'appsumo' => !empty(config('services.appsumo.api_key')) && !empty(config('services.appsumo.api_secret')), + ], + 'storage' => [ + 'local' => config('filesystems.default') === 'local', + 's3' => config('filesystems.default') !== 'local', + ], + 'services' => [ + 'unsplash' => !empty(config('services.unsplash.access_key')), + 'google' => [ + 'fonts' => !empty(config('services.google.fonts_api_key')), + 'auth' => !empty(config('services.google.client_id')) && !empty(config('services.google.client_secret')), + ], + ], + 'integrations' => [ + 'zapier' => config('services.zapier.enabled'), + 'google_sheets' => !empty(config('services.google.client_id')) && !empty(config('services.google.client_secret')), + ], + ]; + }); + + return response()->json($featureFlags); + } +} diff --git a/api/app/Http/Controllers/FontsController.php b/api/app/Http/Controllers/Content/FontsController.php similarity index 81% rename from api/app/Http/Controllers/FontsController.php rename to api/app/Http/Controllers/Content/FontsController.php index f528b17ff..500131902 100644 --- a/api/app/Http/Controllers/FontsController.php +++ b/api/app/Http/Controllers/Content/FontsController.php @@ -1,14 +1,19 @@ json([]); + } + return \Cache::remember('google_fonts', 60 * 60, function () { $url = "https://www.googleapis.com/webfonts/v1/webfonts?sort=popularity&key=" . config('services.google.fonts_api_key'); $response = Http::get($url); diff --git a/api/app/Http/Controllers/SitemapController.php b/api/app/Http/Controllers/Content/SitemapController.php similarity index 79% rename from api/app/Http/Controllers/SitemapController.php rename to api/app/Http/Controllers/Content/SitemapController.php index 80bcd1579..ee45df303 100644 --- a/api/app/Http/Controllers/SitemapController.php +++ b/api/app/Http/Controllers/Content/SitemapController.php @@ -1,9 +1,10 @@ chunk(100, function ($templates) use (&$urls) { foreach ($templates as $template) { $urls[] = [ - 'loc' => '/templates/'.$template->slug, + 'loc' => '/templates/' . $template->slug, ]; } }); diff --git a/api/app/Http/Controllers/TemplateController.php b/api/app/Http/Controllers/Forms/TemplateController.php similarity index 94% rename from api/app/Http/Controllers/TemplateController.php rename to api/app/Http/Controllers/Forms/TemplateController.php index 4389703c7..c368ccb3f 100644 --- a/api/app/Http/Controllers/TemplateController.php +++ b/api/app/Http/Controllers/Forms/TemplateController.php @@ -1,12 +1,13 @@ where(function ($q) { $q->where('publicly_listed', true) - ->orWhere('creator_id', Auth::id()); + ->orWhere('creator_id', Auth::id()); }); } } else { diff --git a/api/config/services.php b/api/config/services.php index aa2aa87f6..8076ef9a2 100644 --- a/api/config/services.php +++ b/api/config/services.php @@ -74,4 +74,8 @@ 'fonts_api_key' => env('GOOGLE_FONTS_API_KEY'), ], + 'zapier' => [ + 'enabled' => env('ZAPIER_ENABLED', false), + ], + ]; diff --git a/api/routes/api.php b/api/routes/api.php index 8485f83f8..41b0962ea 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -20,8 +20,8 @@ use App\Http\Controllers\Settings\ProfileController; use App\Http\Controllers\Settings\TokenController; use App\Http\Controllers\SubscriptionController; -use App\Http\Controllers\TemplateController; -use App\Http\Controllers\UserInviteController; +use App\Http\Controllers\Forms\TemplateController; +use App\Http\Controllers\Auth\UserInviteController; use App\Http\Controllers\WorkspaceController; use App\Http\Controllers\WorkspaceUserController; use App\Http\Middleware\Form\ResolveFormMiddleware; @@ -309,13 +309,14 @@ * Other public routes */ Route::prefix('content')->name('content.')->group(function () { + Route::get('/feature-flags', [\App\Http\Controllers\Content\FeatureFlagsController::class, 'index'])->name('feature-flags'); Route::get('changelog/entries', [\App\Http\Controllers\Content\ChangelogController::class, 'index'])->name('changelog.entries'); }); -Route::get('/sitemap-urls', [\App\Http\Controllers\SitemapController::class, 'index'])->name('sitemap.index'); +Route::get('/sitemap-urls', [\App\Http\Controllers\Content\SitemapController::class, 'index'])->name('sitemap.index'); // Fonts -Route::get('/fonts', [\App\Http\Controllers\FontsController::class, 'index'])->name('fonts.index'); +Route::get('/fonts', [\App\Http\Controllers\Content\FontsController::class, 'index'])->name('fonts.index'); // Templates Route::prefix('templates')->group(function () { diff --git a/api/tests/Feature/FeatureFlagsControllerTest.php b/api/tests/Feature/FeatureFlagsControllerTest.php new file mode 100644 index 000000000..b7385a470 --- /dev/null +++ b/api/tests/Feature/FeatureFlagsControllerTest.php @@ -0,0 +1,69 @@ +getJson(route('content.feature-flags')); + + // Assert + $response->assertStatus(200) + ->assertJson([ + 'self_hosted' => false, + 'custom_domains' => true, + 'ai_features' => true, + 'billing' => [ + 'enabled' => true, + 'appsumo' => true, + ], + 'storage' => [ + 'local' => false, + 's3' => true, + ], + 'services' => [ + 'unsplash' => true, + 'google' => [ + 'fonts' => true, + 'auth' => true, + ], + ], + 'integrations' => [ + 'zapier' => true, + 'google_sheets' => true, + ], + ]); +}); + +it('caches feature flags', function () { + // Arrange + Cache::shouldReceive('remember') + ->once() + ->withArgs(function ($key, $ttl, $callback) { + return $key === 'feature_flags' && $ttl === 3600 && is_callable($callback); + }) + ->andReturn(['some' => 'data']); + + // Act + $controller = new FeatureFlagsController(); + $response = $controller->index(); + + // Assert + $this->assertEquals(['some' => 'data'], $response->getData(true)); +}); diff --git a/client/.env.docker b/client/.env.docker index 97e969c3a..45af50e40 100644 --- a/client/.env.docker +++ b/client/.env.docker @@ -1,8 +1,4 @@ NUXT_PUBLIC_APP_URL=/ NUXT_PUBLIC_API_BASE=/api NUXT_PRIVATE_API_BASE=http://ingress/api -NUXT_PUBLIC_AI_FEATURES_ENABLED=false -NUXT_PUBLIC_ENV=dev -NUXT_PUBLIC_GOOGLE_ANALYTICS_CODE= -NUXT_PUBLIC_H_CAPTCHA_SITE_KEY= -NUXT_PUBLIC_S3_ENABLED=false \ No newline at end of file +NUXT_PUBLIC_ENV=dev \ No newline at end of file diff --git a/client/.env.example b/client/.env.example index eac82a860..7fe5fcf22 100644 --- a/client/.env.example +++ b/client/.env.example @@ -1,13 +1,9 @@ NUXT_LOG_LEVEL= NUXT_PUBLIC_APP_URL= NUXT_PUBLIC_API_BASE= -NUXT_PUBLIC_AI_FEATURES_ENABLED= NUXT_PUBLIC_AMPLITUDE_CODE= NUXT_PUBLIC_CRISP_WEBSITE_ID= -NUXT_PUBLIC_CUSTOM_DOMAINS_ENABLED= NUXT_PUBLIC_ENV= NUXT_PUBLIC_GOOGLE_ANALYTICS_CODE= NUXT_PUBLIC_H_CAPTCHA_SITE_KEY= -NUXT_PUBLIC_PAID_PLANS_ENABLED= -NUXT_PUBLIC_S3_ENABLED= NUXT_API_SECRET=secret diff --git a/client/components/forms/DateInput.vue b/client/components/forms/DateInput.vue index 0cdcb14bf..ae95c30af 100644 --- a/client/components/forms/DateInput.vue +++ b/client/components/forms/DateInput.vue @@ -71,7 +71,7 @@ :max-date="maxDate" :is-dark="props.isDark" color="form-color" - @update:modelValue="updateModelValue" + @update:model-value="updateModelValue" /> @@ -201,7 +201,7 @@ const formattedDate = (value) => { try { return format(new Date(value), props.dateFormat + (props.timeFormat == 12 ? ' p':' HH:mm')) } catch (e) { - console.log(e) + console.log('Error formatting date', e) return '' } } diff --git a/client/components/global/Navbar.vue b/client/components/global/Navbar.vue index 71bbf413b..0cab4092a 100644 --- a/client/components/global/Navbar.vue +++ b/client/components/global/Navbar.vue @@ -54,7 +54,7 @@