Skip to content

Commit 79d3dd7

Browse files
authored
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
1 parent 1dffd27 commit 79d3dd7

40 files changed

+304
-147
lines changed

api/.env.docker

+3-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ APP_KEY=
44
APP_DEBUG=false
55
APP_URL=http://localhost
66

7+
SELF_HOSTED=true
8+
79
LOG_CHANNEL=errorlog
810
LOG_LEVEL=debug
911

@@ -43,5 +45,4 @@ JWT_SECRET=
4345
MUX_WORKSPACE_ID=
4446
MUX_API_TOKEN=
4547

46-
OPEN_AI_API_KEY=
47-
SELF_HOSTED=true
48+
OPEN_AI_API_KEY=

api/.env.example

+4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ APP_DEBUG=true
55
APP_LOG_LEVEL=debug
66
APP_URL=http://localhost
77

8+
SELF_HOSTED=true
9+
810
LOG_CHANNEL=stack
911
LOG_LEVEL=debug
1012

@@ -87,3 +89,5 @@ GOOGLE_REDIRECT_URL=http://localhost:3000/settings/connections/callback/google
8789
GOOGLE_AUTH_REDIRECT_URL=http://localhost:3000/oauth/google/callback
8890

8991
GOOGLE_FONTS_API_KEY=
92+
93+
ZAPIER_ENABLED=false

api/app/Http/Controllers/UserInviteController.php api/app/Http/Controllers/Auth/UserInviteController.php

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
<?php
22

3-
namespace App\Http\Controllers;
3+
namespace App\Http\Controllers\Auth;
44

55
use App\Models\UserInvite;
66
use App\Models\Workspace;
77
use App\Service\WorkspaceHelper;
88
use Illuminate\Http\Request;
9+
use App\Http\Controllers\Controller;
910

1011
class UserInviteController extends Controller
1112
{
@@ -31,7 +32,7 @@ public function resendInvite($workspaceId, $inviteId)
3132
return $this->error(['success' => false, 'message' => 'Invite not found for this workspace.']);
3233
}
3334

34-
if($userInvite->status == UserInvite::ACCEPTED_STATUS) {
35+
if ($userInvite->status == UserInvite::ACCEPTED_STATUS) {
3536
return $this->error(['success' => false, 'message' => 'Invite already accepted.']);
3637
}
3738

@@ -49,7 +50,7 @@ public function cancelInvite($workspaceId, $inviteId)
4950
return $this->error(['success' => false, 'message' => 'Invite not found for this workspace.']);
5051
}
5152

52-
if($userInvite->status == UserInvite::ACCEPTED_STATUS) {
53+
if ($userInvite->status == UserInvite::ACCEPTED_STATUS) {
5354
return $this->error(['success' => false, 'message' => 'Invite already accepted.']);
5455
}
5556

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Content;
4+
5+
use App\Http\Controllers\Controller;
6+
7+
class FeatureFlagsController extends Controller
8+
{
9+
public function index()
10+
{
11+
$featureFlags = \Cache::remember('feature_flags', 3600, function () {
12+
return [
13+
'self_hosted' => config('app.self_hosted', true),
14+
'custom_domains' => config('custom_domains.enabled', false),
15+
'ai_features' => !empty(config('services.openai.api_key')),
16+
17+
'billing' => [
18+
'enabled' => !empty(config('cashier.key')) && !empty(config('cashier.secret')),
19+
'appsumo' => !empty(config('services.appsumo.api_key')) && !empty(config('services.appsumo.api_secret')),
20+
],
21+
'storage' => [
22+
'local' => config('filesystems.default') === 'local',
23+
's3' => config('filesystems.default') !== 'local',
24+
],
25+
'services' => [
26+
'unsplash' => !empty(config('services.unsplash.access_key')),
27+
'google' => [
28+
'fonts' => !empty(config('services.google.fonts_api_key')),
29+
'auth' => !empty(config('services.google.client_id')) && !empty(config('services.google.client_secret')),
30+
],
31+
],
32+
'integrations' => [
33+
'zapier' => config('services.zapier.enabled'),
34+
'google_sheets' => !empty(config('services.google.client_id')) && !empty(config('services.google.client_secret')),
35+
],
36+
];
37+
});
38+
39+
return response()->json($featureFlags);
40+
}
41+
}

api/app/Http/Controllers/FontsController.php api/app/Http/Controllers/Content/FontsController.php

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
<?php
22

3-
namespace App\Http\Controllers;
3+
namespace App\Http\Controllers\Content;
44

55
use Illuminate\Http\Request;
66
use Illuminate\Support\Facades\Http;
7+
use App\Http\Controllers\Controller;
78

89
class FontsController extends Controller
910
{
1011
public function index(Request $request)
1112
{
13+
if (!config('services.google.fonts_api_key')) {
14+
return response()->json([]);
15+
}
16+
1217
return \Cache::remember('google_fonts', 60 * 60, function () {
1318
$url = "https://www.googleapis.com/webfonts/v1/webfonts?sort=popularity&key=" . config('services.google.fonts_api_key');
1419
$response = Http::get($url);

api/app/Http/Controllers/SitemapController.php api/app/Http/Controllers/Content/SitemapController.php

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
<?php
22

3-
namespace App\Http\Controllers;
3+
namespace App\Http\Controllers\Content;
44

55
use App\Models\Template;
66
use Illuminate\Http\Request;
7+
use App\Http\Controllers\Controller;
78

89
class SitemapController extends Controller
910
{
@@ -20,7 +21,7 @@ private function getTemplatesUrls()
2021
Template::where('publicly_listed', true)->chunk(100, function ($templates) use (&$urls) {
2122
foreach ($templates as $template) {
2223
$urls[] = [
23-
'loc' => '/templates/'.$template->slug,
24+
'loc' => '/templates/' . $template->slug,
2425
];
2526
}
2627
});

api/app/Http/Controllers/TemplateController.php api/app/Http/Controllers/Forms/TemplateController.php

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
<?php
22

3-
namespace App\Http\Controllers;
3+
namespace App\Http\Controllers\Forms;
44

55
use App\Http\Requests\Templates\FormTemplateRequest;
66
use App\Http\Resources\FormTemplateResource;
77
use App\Models\Template;
88
use Illuminate\Http\Request;
99
use Illuminate\Support\Facades\Auth;
10+
use App\Http\Controllers\Controller;
1011

1112
class TemplateController extends Controller
1213
{
@@ -23,7 +24,7 @@ public function index(Request $request)
2324
} else {
2425
$query->where(function ($q) {
2526
$q->where('publicly_listed', true)
26-
->orWhere('creator_id', Auth::id());
27+
->orWhere('creator_id', Auth::id());
2728
});
2829
}
2930
} else {

api/config/services.php

+4
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,8 @@
7474
'fonts_api_key' => env('GOOGLE_FONTS_API_KEY'),
7575
],
7676

77+
'zapier' => [
78+
'enabled' => env('ZAPIER_ENABLED', false),
79+
],
80+
7781
];

api/routes/api.php

+5-4
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@
2020
use App\Http\Controllers\Settings\ProfileController;
2121
use App\Http\Controllers\Settings\TokenController;
2222
use App\Http\Controllers\SubscriptionController;
23-
use App\Http\Controllers\TemplateController;
24-
use App\Http\Controllers\UserInviteController;
23+
use App\Http\Controllers\Forms\TemplateController;
24+
use App\Http\Controllers\Auth\UserInviteController;
2525
use App\Http\Controllers\WorkspaceController;
2626
use App\Http\Controllers\WorkspaceUserController;
2727
use App\Http\Middleware\Form\ResolveFormMiddleware;
@@ -309,13 +309,14 @@
309309
* Other public routes
310310
*/
311311
Route::prefix('content')->name('content.')->group(function () {
312+
Route::get('/feature-flags', [\App\Http\Controllers\Content\FeatureFlagsController::class, 'index'])->name('feature-flags');
312313
Route::get('changelog/entries', [\App\Http\Controllers\Content\ChangelogController::class, 'index'])->name('changelog.entries');
313314
});
314315

315-
Route::get('/sitemap-urls', [\App\Http\Controllers\SitemapController::class, 'index'])->name('sitemap.index');
316+
Route::get('/sitemap-urls', [\App\Http\Controllers\Content\SitemapController::class, 'index'])->name('sitemap.index');
316317

317318
// Fonts
318-
Route::get('/fonts', [\App\Http\Controllers\FontsController::class, 'index'])->name('fonts.index');
319+
Route::get('/fonts', [\App\Http\Controllers\Content\FontsController::class, 'index'])->name('fonts.index');
319320

320321
// Templates
321322
Route::prefix('templates')->group(function () {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
use App\Http\Controllers\Content\FeatureFlagsController;
4+
use Illuminate\Support\Facades\Cache;
5+
use Illuminate\Support\Facades\Config;
6+
7+
it('returns feature flags', function () {
8+
// Arrange
9+
Config::set('app.self_hosted', false);
10+
Config::set('custom_domains.enabled', true);
11+
Config::set('cashier.key', 'stripe_key');
12+
Config::set('cashier.secret', 'stripe_secret');
13+
Config::set('services.appsumo.api_key', 'appsumo_key');
14+
Config::set('services.appsumo.api_secret', 'appsumo_secret');
15+
Config::set('filesystems.default', 's3');
16+
Config::set('services.openai.api_key', 'openai_key');
17+
Config::set('services.unsplash.access_key', 'unsplash_key');
18+
Config::set('services.google.fonts_api_key', 'google_fonts_key');
19+
Config::set('services.google.client_id', 'google_client_id');
20+
Config::set('services.google.client_secret', 'google_client_secret');
21+
Config::set('services.zapier.enabled', true);
22+
23+
// Act
24+
$response = $this->getJson(route('content.feature-flags'));
25+
26+
// Assert
27+
$response->assertStatus(200)
28+
->assertJson([
29+
'self_hosted' => false,
30+
'custom_domains' => true,
31+
'ai_features' => true,
32+
'billing' => [
33+
'enabled' => true,
34+
'appsumo' => true,
35+
],
36+
'storage' => [
37+
'local' => false,
38+
's3' => true,
39+
],
40+
'services' => [
41+
'unsplash' => true,
42+
'google' => [
43+
'fonts' => true,
44+
'auth' => true,
45+
],
46+
],
47+
'integrations' => [
48+
'zapier' => true,
49+
'google_sheets' => true,
50+
],
51+
]);
52+
});
53+
54+
it('caches feature flags', function () {
55+
// Arrange
56+
Cache::shouldReceive('remember')
57+
->once()
58+
->withArgs(function ($key, $ttl, $callback) {
59+
return $key === 'feature_flags' && $ttl === 3600 && is_callable($callback);
60+
})
61+
->andReturn(['some' => 'data']);
62+
63+
// Act
64+
$controller = new FeatureFlagsController();
65+
$response = $controller->index();
66+
67+
// Assert
68+
$this->assertEquals(['some' => 'data'], $response->getData(true));
69+
});

client/.env.docker

+1-5
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
11
NUXT_PUBLIC_APP_URL=/
22
NUXT_PUBLIC_API_BASE=/api
33
NUXT_PRIVATE_API_BASE=http://ingress/api
4-
NUXT_PUBLIC_AI_FEATURES_ENABLED=false
5-
NUXT_PUBLIC_ENV=dev
6-
NUXT_PUBLIC_GOOGLE_ANALYTICS_CODE=
7-
NUXT_PUBLIC_H_CAPTCHA_SITE_KEY=
8-
NUXT_PUBLIC_S3_ENABLED=false
4+
NUXT_PUBLIC_ENV=dev

client/.env.example

-4
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
NUXT_LOG_LEVEL=
22
NUXT_PUBLIC_APP_URL=
33
NUXT_PUBLIC_API_BASE=
4-
NUXT_PUBLIC_AI_FEATURES_ENABLED=
54
NUXT_PUBLIC_AMPLITUDE_CODE=
65
NUXT_PUBLIC_CRISP_WEBSITE_ID=
7-
NUXT_PUBLIC_CUSTOM_DOMAINS_ENABLED=
86
NUXT_PUBLIC_ENV=
97
NUXT_PUBLIC_GOOGLE_ANALYTICS_CODE=
108
NUXT_PUBLIC_H_CAPTCHA_SITE_KEY=
11-
NUXT_PUBLIC_PAID_PLANS_ENABLED=
12-
NUXT_PUBLIC_S3_ENABLED=
139
NUXT_API_SECRET=secret

client/components/forms/DateInput.vue

+3-3
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
:max-date="maxDate"
7272
:is-dark="props.isDark"
7373
color="form-color"
74-
@update:modelValue="updateModelValue"
74+
@update:model-value="updateModelValue"
7575
/>
7676
<DatePicker
7777
v-else
@@ -84,7 +84,7 @@
8484
:max-date="maxDate"
8585
:is-dark="props.isDark"
8686
color="form-color"
87-
@update:modelValue="updateModelValue"
87+
@update:model-value="updateModelValue"
8888
/>
8989
</template>
9090
</UPopover>
@@ -201,7 +201,7 @@ const formattedDate = (value) => {
201201
try {
202202
return format(new Date(value), props.dateFormat + (props.timeFormat == 12 ? ' p':' HH:mm'))
203203
} catch (e) {
204-
console.log(e)
204+
console.log('Error formatting date', e)
205205
return ''
206206
}
207207
}

client/components/global/Navbar.vue

+6-4
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
</a>
5555
</template>
5656
<NuxtLink
57-
v-if="($route.name !== 'ai-form-builder' && user === null) && (!appStore.selfHosted || appStore.aiFeaturesEnabled)"
57+
v-if="($route.name !== 'ai-form-builder' && user === null) && (!useFeatureFlag('self_hosted') || useFeatureFlag('ai_features'))"
5858
:to="{ name: 'ai-form-builder' }"
5959
:class="navLinkClasses"
6060
class="hidden lg:inline"
@@ -63,9 +63,9 @@
6363
</NuxtLink>
6464
<NuxtLink
6565
v-if="
66-
(appStore.paidPlansEnabled &&
66+
(useFeatureFlag('billing.enabled') &&
6767
(user === null || (user && workspace && !workspace.is_pro)) &&
68-
$route.name !== 'pricing') && !appStore.selfHosted
68+
$route.name !== 'pricing') && !isSelfHosted
6969
"
7070
:to="{ name: 'pricing' }"
7171
:class="navLinkClasses"
@@ -248,7 +248,7 @@
248248
</NuxtLink>
249249

250250
<v-button
251-
v-if="!appStore.selfHosted"
251+
v-if="!isSelfHosted"
252252
v-track.nav_create_form_click
253253
size="small"
254254
class="shrink-0"
@@ -274,6 +274,7 @@ import Dropdown from "~/components/global/Dropdown.vue"
274274
import WorkspaceDropdown from "./WorkspaceDropdown.vue"
275275
import opnformConfig from "~/opnform.config.js"
276276
import { useRuntimeConfig } from "#app"
277+
import { useFeatureFlag } from "~/composables/useFeatureFlag"
277278
278279
export default {
279280
components: {
@@ -294,6 +295,7 @@ export default {
294295
config: useRuntimeConfig(),
295296
user: computed(() => authStore.user),
296297
isIframe: useIsIframe(),
298+
isSelfHosted: computed(() => useFeatureFlag('self_hosted')),
297299
}
298300
},
299301

client/components/global/ProTag.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const user = computed(() => authStore.user)
3333
const workspace = computed(() => workspacesStore.getCurrent)
3434
3535
const shouldDisplayProTag = computed(() => {
36-
if (!useRuntimeConfig().public.paidPlansEnabled) return false
36+
if (!useFeatureFlag('billing.enabled')) return false
3737
if (!user.value || !workspace.value) return true
3838
return !workspace.value.is_pro
3939
})

0 commit comments

Comments
 (0)