Build a DevCycle Feature Flag Control Panel in Directus
Published January 3rd, 2024
Feature flags are an important part of any agile development process. They allow development teams to roll out features gradually while monitoring their impact on key metrics and users. DevCycle is a popular feature flag platform that helps manage flags easily.
In this post, I'll show you how to build a custom panel extension in Directus that integrates with DevCycle's API. This will allow non-technical users to manage feature flags directly from the Directus Insights Dashboard.
Getting Started
To complete this tutorial, you'll need the following:
- A Directus project.
- A DevCycle account.
- Familiarity with Node.js and Vue.js.
Create Features within DevCycle
We’ll begin by adding a couple of features to DevCycle. Imagine we’re developing a task-tracking app and need to introduce three additional features behind a feature flag.
Log in to DevCycle to add these features with three variables each:
- Task Time Tracking
- Real-Time Collaboration
- Custom Workflows
Sample Variables
Feature: Task Time Tracking
Variables | Type | Variation On | Variation Off |
---|---|---|---|
task-time-tracking-enabled | Booelan | true | false |
time-tracking-roles | String | administrator | collaborator |
time-tracking-max-hours | Number | 20 | 10 |
Feature: Real-Time Collaboration
Variables | Type | Variation On | Variation Off |
---|---|---|---|
real-time-collaboration-enabled | Booelan | true | false |
collaboration-file-sharing | Booelan | true | false |
collaboration-editing-permissions | String | full | limited |
Feature: Custom Workflows
Variables | Type | Variation On | Variation Off |
---|---|---|---|
custom-workflows-enabled | Booelan | true | false |
workflow-templates-enabled | Booelan | true | false |
workflow-step-limits | Number | 10 | 5 |
Get Authorization Token from DevCycle
The DevCycle Management API manages features and variables on the platform. You need an authorization token to interact with DevCycle and manage the features we created earlier.
Open your terminal, and run the code below, replacing values in angled brackets with your credentials:
curl --request POST \
--url "https://auth.devcycle.com/oauth/token" \
--header 'content-type: application/x-www-form-urlencoded' \
--data grant_type=client_credentials \
--data audience=https://api.devcycle.com/ \
--data client_id=<client id> \
--data client_secret=<client secret>
curl --request POST \
--url "https://auth.devcycle.com/oauth/token" \
--header 'content-type: application/x-www-form-urlencoded' \
--data grant_type=client_credentials \
--data audience=https://api.devcycle.com/ \
--data client_id=<client id> \
--data client_secret=<client secret>
Authorization Token
Obtain an authorization token (https://auth.devcycle.com/oauth/token) using the Client ID and Client Secret from the DevCycle dashboard.
Take note of the token, you’ll be using it shortly.
Develop the Extension
After spinning up a Self-Hosted Directus Instance, inside your docker-compose.yml
file, ensure there is a volume to allow for custom extensions:
volumes:
- ./database:/directus/database
- ./uploads:/directus/uploads
- ./extensions:/directus/extensions
volumes:
- ./database:/directus/database
- ./uploads:/directus/uploads
- ./extensions:/directus/extensions
Now it's time to build and start our Docker container. Run the command docker compose up
to start up docker.
Scaffold a Bundle Extension
Panels can only communicate with internal Directus services, and are prevented from making cross-origin requests. To build a panel that can interact with third-party APIs, you need to create a bundle extension. The bundle extension contains both an endpoint extension and a panel extension.
Navigate to the extensions directory and use the Directus extensions command npx create-directus-extension@latest
to create an empty bundle:
npx create-directus-extension@latest
├ type: bundle
├ name: directus-extension-devcyle
└ language: typescript
npx create-directus-extension@latest
├ type: bundle
├ name: directus-extension-devcyle
└ language: typescript
Now, cd
into the newly-created extension directory.
Create an Endpoint Extension
The endpoint extension handles API calls to DevCycle. Go ahead to add an endpoint extension to the bundle using the command npm run add
npm run add
├ type: endpoint
├ name: devcycle-endpoint
└ language: typescript
npm run add
├ type: endpoint
├ name: devcycle-endpoint
└ language: typescript
Go into the newly created devcycle-endpoint/index.ts
file and replace it with the following:
import { defineEndpoint } from '@directus/extensions-sdk';
export default defineEndpoint((router, context) => {
const { env } = context;
const headers = new Headers();
headers.append('Authorization', `Bearer ${env.DEVCYCLE_TOKEN}`);
headers.append('Content-Type', 'application/json');
// get a project
router.get('/', async (req, res) => {
if (req.accountability?.user == null) {
return res.status(403).send(`You don't have permission to access this.`);
}
const response = await fetch('https://api.devcycle.com/v1/projects', {
headers,
});
const result = await response.json();
res.json(result);
});
// get all features
router.get('/:project/features', async (req, res) => {
if (req.accountability?.user == null) {
return res.status(403).send(`You don't have permission to access this.`);
}
const response = await fetch(
`https://api.devcycle.com/v1/projects/${req.params.project}/features`,
{
headers,
}
);
const result = await response.json();
res.json(result);
});
// get feature
router.get('/:project/features/:feature', async (req, res) => {
if (req.accountability?.user == null) {
return res.status(403).send(`You don't have permission to access this.`);
}
const { project, feature } = req.params;
const response = await fetch(
`https://api.devcycle.com/v1/projects/${project}/features/${feature}`,
{
headers,
}
);
const result = await response.json();
res.json(result);
});
// update feature
router.patch('/:project/features/:feature', async (req, res) => {
if (req.accountability?.user == null) {
return res.status(403).send(`You don't have permission to access this.`);
}
const { project, feature } = req.params;
const updatedData = req.body;
const response = await fetch(
`https://api.devcycle.com/v1/projects/${project}/features/${feature}`,
{
method: 'PATCH',
headers,
body: JSON.stringify(updatedData),
}
);
const result = await response.json();
res.json(result);
});
// update feature variation
router.patch(
'/:project/features/:feature/variations/:variation',
async (req, res) => {
if (req.accountability?.user == null) {
res.status(403);
return res.send(`You don't have permission to access this.`);
}
const { project, feature, variation } = req.params;
const updatedData = req.body;
const response = await fetch(
`https://api.devcycle.com/v1/projects/${project}/features/${feature}/variations/${variation}`,
{
method: 'PATCH',
headers,
body: JSON.stringify(updatedData),
}
);
const result = await response.json();
res.json(result);
}
);
});
import { defineEndpoint } from '@directus/extensions-sdk';
export default defineEndpoint((router, context) => {
const { env } = context;
const headers = new Headers();
headers.append('Authorization', `Bearer ${env.DEVCYCLE_TOKEN}`);
headers.append('Content-Type', 'application/json');
// get a project
router.get('/', async (req, res) => {
if (req.accountability?.user == null) {
return res.status(403).send(`You don't have permission to access this.`);
}
const response = await fetch('https://api.devcycle.com/v1/projects', {
headers,
});
const result = await response.json();
res.json(result);
});
// get all features
router.get('/:project/features', async (req, res) => {
if (req.accountability?.user == null) {
return res.status(403).send(`You don't have permission to access this.`);
}
const response = await fetch(
`https://api.devcycle.com/v1/projects/${req.params.project}/features`,
{
headers,
}
);
const result = await response.json();
res.json(result);
});
// get feature
router.get('/:project/features/:feature', async (req, res) => {
if (req.accountability?.user == null) {
return res.status(403).send(`You don't have permission to access this.`);
}
const { project, feature } = req.params;
const response = await fetch(
`https://api.devcycle.com/v1/projects/${project}/features/${feature}`,
{
headers,
}
);
const result = await response.json();
res.json(result);
});
// update feature
router.patch('/:project/features/:feature', async (req, res) => {
if (req.accountability?.user == null) {
return res.status(403).send(`You don't have permission to access this.`);
}
const { project, feature } = req.params;
const updatedData = req.body;
const response = await fetch(
`https://api.devcycle.com/v1/projects/${project}/features/${feature}`,
{
method: 'PATCH',
headers,
body: JSON.stringify(updatedData),
}
);
const result = await response.json();
res.json(result);
});
// update feature variation
router.patch(
'/:project/features/:feature/variations/:variation',
async (req, res) => {
if (req.accountability?.user == null) {
res.status(403);
return res.send(`You don't have permission to access this.`);
}
const { project, feature, variation } = req.params;
const updatedData = req.body;
const response = await fetch(
`https://api.devcycle.com/v1/projects/${project}/features/${feature}/variations/${variation}`,
{
method: 'PATCH',
headers,
body: JSON.stringify(updatedData),
}
);
const result = await response.json();
res.json(result);
}
);
});
Remember the auth token generated earlier from DevCyle? Go into your docker-compose.yml
file and paste that token.
DEVCYCLE_TOKEN: // Put Token Here
DEVCYCLE_TOKEN: // Put Token Here
Generate New Authorization Tokens
Authorization Tokens from DevCycle expire, so it's crucial to implement a system to generate new tokens when needed. Below I provide a standalone function that can be called manually, but automating this would be important in production.
Inside the devcycle-endpoint
directory, create a new file called token.ts
and paste the following:
export async function getAuthToken(env: Record<string, any>) {
// create a request header
const headers = new Headers();
headers.append('Content-Type', 'application/x-www-form-urlencoded');
// create a url params
const urlencoded = new URLSearchParams();
urlencoded.append('grant_type', 'client_credentials');
urlencoded.append('audience', 'https://api.devcycle.com/');
urlencoded.append('client_id', env.DEVCYCLE_CLIENT_ID);
urlencoded.append('client_secret', env.DEVCYCLE_CLIENT_SECRET);
return fetch('https://auth.devcycle.com/oauth/token', {
method: 'POST',
headers: headers,
body: urlencoded,
redirect: 'follow',
});
}
export async function getAuthToken(env: Record<string, any>) {
// create a request header
const headers = new Headers();
headers.append('Content-Type', 'application/x-www-form-urlencoded');
// create a url params
const urlencoded = new URLSearchParams();
urlencoded.append('grant_type', 'client_credentials');
urlencoded.append('audience', 'https://api.devcycle.com/');
urlencoded.append('client_id', env.DEVCYCLE_CLIENT_ID);
urlencoded.append('client_secret', env.DEVCYCLE_CLIENT_SECRET);
return fetch('https://auth.devcycle.com/oauth/token', {
method: 'POST',
headers: headers,
body: urlencoded,
redirect: 'follow',
});
}
Create a Panel Extension
The panel extension is used to display features from the DevCycle API. It also allows these features to be updated directly from within Directus.
Inside of your bundle extension directory, run npm run add
to add a new panel extension to the bundle.
npm run add
├ type: panel
├ name: devcyle-panel
└ language: javascript
npm run add
├ type: panel
├ name: devcyle-panel
└ language: javascript
Navigate into devycle-panel/index.ts
and replace the code with the following:
import { definePanel } from '@directus/extensions-sdk';
import PanelComponent from './panel.vue';
export default definePanel({
id: 'devcyle-panel',
name: 'Devcycle',
icon: 'cycle',
description: 'This is a DevCycle panel!',
component: PanelComponent,
options: [{
field: 'project',
name: 'Project',
type: 'string',
meta: {
interface: 'input',
width: 'full',
},
}],
minWidth: 12,
minHeight: 8,
});
import { definePanel } from '@directus/extensions-sdk';
import PanelComponent from './panel.vue';
export default definePanel({
id: 'devcyle-panel',
name: 'Devcycle',
icon: 'cycle',
description: 'This is a DevCycle panel!',
component: PanelComponent,
options: [{
field: 'project',
name: 'Project',
type: 'string',
meta: {
interface: 'input',
width: 'full',
},
}],
minWidth: 12,
minHeight: 8,
});
Notice we updated the id
, name
, icon
and description
. Within the options array, we also modified the field
and name
to match this panel.
List and Update Features
Go into devcycle-panel/panel.vue
. Here, we’ll make a couple of updates to list features, display feature details and update features.
List Features
To retrieve and work with features from our API, we start with defining the TypeScript schema that models the structure of a Feature
, Variable
and Variation
.
interface Feature {
name: string;
key: string;
description: string;
controlVariation: string;
variations: Variation[];
variables: Variable[];
}
export type Variables = Record<string, any>;
export interface Variable {
name: string;
key: string;
type: string;
status: string;
defaultValue?: boolean;
}
export interface Variation {
_id: string;
key: string;
name: string;
variables: Variables;
}
interface Feature {
name: string;
key: string;
description: string;
controlVariation: string;
variations: Variation[];
variables: Variable[];
}
export type Variables = Record<string, any>;
export interface Variable {
name: string;
key: string;
type: string;
status: string;
defaultValue?: boolean;
}
export interface Variation {
_id: string;
key: string;
name: string;
variables: Variables;
}
Next, update the props to use the “project” value.
export default defineComponent({
props: {
showHeader: {
type: Boolean,
default: false,
},
project: {
type: String,
default: '',
},
},
}
export default defineComponent({
props: {
showHeader: {
type: Boolean,
default: false,
},
project: {
type: String,
default: '',
},
},
}
Create a setup
hook that calls useApi()
to fetch the features from the endpoint extension.
setup(props) {
const api = useApi();
const data = ref<Feature[]>([]);
async function fetchFeatures() {
const response = await api.get(
`/devcycle-endpoint/${props.project}/features`
);
data.value = response.data;
}
fetchFeatures();
return { data };
}
setup(props) {
const api = useApi();
const data = ref<Feature[]>([]);
async function fetchFeatures() {
const response = await api.get(
`/devcycle-endpoint/${props.project}/features`
);
data.value = response.data;
}
fetchFeatures();
return { data };
}
Finally, display the features by updating the templates and styles.
<template>
<div class="container">
<h2>Features</h2>
<div class="features" v-if="!activeFeatureKey">
<button
v-for="(item, index) in data"
:key="index"
>
{{ item.name }}
</button>
</div>
</div>
</template>
<script lang="ts">
// script section
</script>
<style scoped>
.container {
padding: 24px;
color: black;
}
.container h2 {
font-size: 24px;
font-weight: 600;
}
.container.has-header {
padding: 0 12px;
}
.container button {
margin-block: 24px;
text-align: left;
width: 100%;
}
.features {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
}
.features button {
font-size: 18px;
}
</style>
<template>
<div class="container">
<h2>Features</h2>
<div class="features" v-if="!activeFeatureKey">
<button
v-for="(item, index) in data"
:key="index"
>
{{ item.name }}
</button>
</div>
</div>
</template>
<script lang="ts">
// script section
</script>
<style scoped>
.container {
padding: 24px;
color: black;
}
.container h2 {
font-size: 24px;
font-weight: 600;
}
.container.has-header {
padding: 0 12px;
}
.container button {
margin-block: 24px;
text-align: left;
width: 100%;
}
.features {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
}
.features button {
font-size: 18px;
}
</style>
Display Feature Details
When a feature is clicked, we need to be able to display the variations and variables of this feature. Within the setup
hook, add the following:
const activeFeatureKey = ref<string>();
const activeVariationKey = ref<string>();
const activeFeatureKey = ref<string>();
const activeVariationKey = ref<string>();
Also, add in a computed property that evaluates these variables
computed: {
activeFeature(): Feature | undefined {
return this.data.find((item) => item.key === this.activeFeatureKey);
},
activeVariation(): Variation | undefined {
return this.activeFeature?.variations.find(
(item) => item.key === this.activeVariationKey
);
},
}
computed: {
activeFeature(): Feature | undefined {
return this.data.find((item) => item.key === this.activeFeatureKey);
},
activeVariation(): Variation | undefined {
return this.activeFeature?.variations.find(
(item) => item.key === this.activeVariationKey
);
},
}
To display the features in the panel, update the templates
code with the following:
<div class="active-feature" v-if="activeFeature">
<h2>{{ activeFeature.name }}</h2>
<p>{{ activeFeature.description }}</p>
<form>
<select
:value="activeVariationKey"
@change="(e) => (activeVariationKey = (e.target as HTMLSelectElement).value)"
>
<option
v-for="(item, index) in activeFeature.variations"
:key="index"
:value="item.key"
>
{{ item.name }}
</option>
</select>
<div v-for="(item, index) in activeFeature.variables" :key="index">
<label :for="item.name">{{ item.key }}</label>
<input
v-if="variableTypes[item.key] === 'Boolean'"
type="checkbox"
:name="item.name"
:checked="activeVariation?.variables[item.key]"
/>
<input
v-if="variableTypes[item.key] === 'String'"
:name="item.name"
:value="activeVariation?.variables[item.key]"
/>
<input
v-if="variableTypes[item.key] === 'Number'"
type="number"
:name="item.name"
:value="activeVariation?.variables[item.key]"
/>
</div>
</form>
</div>
<div class="active-feature" v-if="activeFeature">
<h2>{{ activeFeature.name }}</h2>
<p>{{ activeFeature.description }}</p>
<form>
<select
:value="activeVariationKey"
@change="(e) => (activeVariationKey = (e.target as HTMLSelectElement).value)"
>
<option
v-for="(item, index) in activeFeature.variations"
:key="index"
:value="item.key"
>
{{ item.name }}
</option>
</select>
<div v-for="(item, index) in activeFeature.variables" :key="index">
<label :for="item.name">{{ item.key }}</label>
<input
v-if="variableTypes[item.key] === 'Boolean'"
type="checkbox"
:name="item.name"
:checked="activeVariation?.variables[item.key]"
/>
<input
v-if="variableTypes[item.key] === 'String'"
:name="item.name"
:value="activeVariation?.variables[item.key]"
/>
<input
v-if="variableTypes[item.key] === 'Number'"
type="number"
:name="item.name"
:value="activeVariation?.variables[item.key]"
/>
</div>
</form>
</div>
Update Features
To save any changes to a feature back to the API, we need functions to handle submitting the updated data. Within the setup() function, we define:
- An
updateFeature()
method that will make the API request to save the changes - A
submitVariation()
method to handle submitting the form data
setup(props) {
// existing code
function submitVariation(e: Event) {
e.preventDefault();
const form = e.currentTarget as HTMLFormElement;
const formData = new FormData(form);
const entries = Object.keys(this.activeVariation.variables).map((key) => {
let value = formData.get(key) as any;
const type = this.variableTypes[key];
if (type === 'Boolean') value = Boolean(value);
else if (type === 'Number') value = Number(value);
return [key, value];
});
const variables = Object.fromEntries(entries);
updateFeature({ variables });
}
return {
data,
activeFeatureKey,
activeVariationKey,
updateFeature,
submitVariation,
};
},
setup(props) {
// existing code
function submitVariation(e: Event) {
e.preventDefault();
const form = e.currentTarget as HTMLFormElement;
const formData = new FormData(form);
const entries = Object.keys(this.activeVariation.variables).map((key) => {
let value = formData.get(key) as any;
const type = this.variableTypes[key];
if (type === 'Boolean') value = Boolean(value);
else if (type === 'Number') value = Number(value);
return [key, value];
});
const variables = Object.fromEntries(entries);
updateFeature({ variables });
}
return {
data,
activeFeatureKey,
activeVariationKey,
updateFeature,
submitVariation,
};
},
To enable the changes to be made in the panel, we link the form submission handler defined in setup()
to the form element in the template. We also include a submit button to trigger save.
<form @submit="submitVariation">
<select
:value="activeVariationKey"
@change="(e) => (activeVariationKey = (e.target as HTMLSelectElement).value)"
>
// existing code
<div>
<button type="submit" class="save-button">Save</button>
</div>
</select>
</form>
<form @submit="submitVariation">
<select
:value="activeVariationKey"
@change="(e) => (activeVariationKey = (e.target as HTMLSelectElement).value)"
>
// existing code
<div>
<button type="submit" class="save-button">Save</button>
</div>
</select>
</form>
Reference full `devycle-panel/panel.vue` code
<template>
<div class="container">
<h2>Features</h2>
<button
@click="() => (activeFeatureKey = undefined)"
v-if="activeFeatureKey"
>
Back
</button>
<div class="features" v-if="!activeFeatureKey">
<button
v-for="(item, index) in data"
:key="index"
@click="
() => {
activeFeatureKey = item.key;
activeVariationKey = item.controlVariation;
}
"
>
{{ item.name }}
</button>
</div>
<div class="active-feature" v-if="activeFeature">
<h2>{{ activeFeature.name }}</h2>
<p>{{ activeFeature.description }}</p>
<form @submit="submitVariation">
<select
:value="activeVariationKey"
@change="(e) => (activeVariationKey = (e.target as HTMLSelectElement).value)"
>
<option
v-for="(item, index) in activeFeature.variations"
:key="index"
:value="item.key"
>
{{ item.name }}
</option>
</select>
<div v-for="(item, index) in activeFeature.variables" :key="index">
<label :for="item.name">{{ item.key }}</label>
<input
v-if="variableTypes[item.key] === 'Boolean'"
type="checkbox"
:name="item.name"
:checked="activeVariation?.variables[item.key]"
/>
<input
v-if="variableTypes[item.key] === 'String'"
:name="item.name"
:value="activeVariation?.variables[item.key]"
/>
<input
v-if="variableTypes[item.key] === 'Number'"
type="number"
:name="item.name"
:value="activeVariation?.variables[item.key]"
/>
</div>
<div>
<button type="submit" class="save-button">Save</button>
</div>
</form>
</div>
</div>
</template>
<script lang="ts">
import { ref, defineComponent } from 'vue';
import { useApi } from '@directus/extensions-sdk';
interface Feature {
name: string;
key: string;
description: string;
controlVariation: string;
variations: Variation[];
variables: Variable[];
}
export type Variables = Record<string, any>;
export interface Variable {
name: string;
key: string;
type: string;
status: string;
defaultValue?: boolean;
}
export interface Variation {
_id: string;
key: string;
name: string;
variables: Variables;
}
export default defineComponent({
props: {
showHeader: {
type: Boolean,
default: false,
},
project: {
type: String,
default: '',
},
},
setup(props) {
const api = useApi();
const data = ref<Feature[]>([]);
const activeFeatureKey = ref<string>();
const activeVariationKey = ref<string>();
async function fetchFeatures() {
const response = await api.get(
`/devcycle-endpoint/${props.project}/features`
);
data.value = response.data;
}
fetchFeatures();
async function updateFeature(updatedData) {
await api.patch(
`/devcycle-endpoint/${props.project}/features/${activeFeatureKey.value}/variations/${activeVariationKey.value}`,
updatedData
);
await fetchFeatures();
}
function submitVariation(e: Event) {
e.preventDefault();
const form = e.currentTarget as HTMLFormElement;
const formData = new FormData(form);
const entries = Object.keys(this.activeVariation.variables).map((key) => {
let value = formData.get(key) as any;
const type = this.variableTypes[key];
if (type === 'Boolean') value = Boolean(value);
else if (type === 'Number') value = Number(value);
return [key, value];
});
const variables = Object.fromEntries(entries);
updateFeature({ variables });
}
return {
data,
activeFeatureKey,
activeVariationKey,
updateFeature,
submitVariation,
};
},
computed: {
activeFeature(): Feature | undefined {
return this.data.find((item) => item.key === this.activeFeatureKey);
},
activeVariation(): Variation | undefined {
return this.activeFeature?.variations.find(
(item) => item.key === this.activeVariationKey
);
},
variableTypes(): Record<string, any> {
const variableTypes = {};
this.data.forEach((item) => {
item.variables.forEach((variable) => {
variableTypes[variable.key] = variable.type;
});
});
return variableTypes;
},
},
});
</script>
<style scoped>
.container {
padding: 24px;
color: black;
}
.container h2 {
font-size: 24px;
font-weight: 600;
}
.container.has-header {
padding: 0 12px;
}
.container button {
margin-block: 24px;
text-align: left;
width: 100%;
}
.features {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
}
.features button {
font-size: 18px;
}
.active-feature h2 {
font-size: 18px;
margin-block: 8px;
}
.active-feature p {
margin-block: 8px;
}
.save-button {
background-color: gray;
border: 1px solid gray;
border-radius: 6px;
color: white;
max-width: fit-content;
padding: 4px;
}
</style>
<template>
<div class="container">
<h2>Features</h2>
<button
@click="() => (activeFeatureKey = undefined)"
v-if="activeFeatureKey"
>
Back
</button>
<div class="features" v-if="!activeFeatureKey">
<button
v-for="(item, index) in data"
:key="index"
@click="
() => {
activeFeatureKey = item.key;
activeVariationKey = item.controlVariation;
}
"
>
{{ item.name }}
</button>
</div>
<div class="active-feature" v-if="activeFeature">
<h2>{{ activeFeature.name }}</h2>
<p>{{ activeFeature.description }}</p>
<form @submit="submitVariation">
<select
:value="activeVariationKey"
@change="(e) => (activeVariationKey = (e.target as HTMLSelectElement).value)"
>
<option
v-for="(item, index) in activeFeature.variations"
:key="index"
:value="item.key"
>
{{ item.name }}
</option>
</select>
<div v-for="(item, index) in activeFeature.variables" :key="index">
<label :for="item.name">{{ item.key }}</label>
<input
v-if="variableTypes[item.key] === 'Boolean'"
type="checkbox"
:name="item.name"
:checked="activeVariation?.variables[item.key]"
/>
<input
v-if="variableTypes[item.key] === 'String'"
:name="item.name"
:value="activeVariation?.variables[item.key]"
/>
<input
v-if="variableTypes[item.key] === 'Number'"
type="number"
:name="item.name"
:value="activeVariation?.variables[item.key]"
/>
</div>
<div>
<button type="submit" class="save-button">Save</button>
</div>
</form>
</div>
</div>
</template>
<script lang="ts">
import { ref, defineComponent } from 'vue';
import { useApi } from '@directus/extensions-sdk';
interface Feature {
name: string;
key: string;
description: string;
controlVariation: string;
variations: Variation[];
variables: Variable[];
}
export type Variables = Record<string, any>;
export interface Variable {
name: string;
key: string;
type: string;
status: string;
defaultValue?: boolean;
}
export interface Variation {
_id: string;
key: string;
name: string;
variables: Variables;
}
export default defineComponent({
props: {
showHeader: {
type: Boolean,
default: false,
},
project: {
type: String,
default: '',
},
},
setup(props) {
const api = useApi();
const data = ref<Feature[]>([]);
const activeFeatureKey = ref<string>();
const activeVariationKey = ref<string>();
async function fetchFeatures() {
const response = await api.get(
`/devcycle-endpoint/${props.project}/features`
);
data.value = response.data;
}
fetchFeatures();
async function updateFeature(updatedData) {
await api.patch(
`/devcycle-endpoint/${props.project}/features/${activeFeatureKey.value}/variations/${activeVariationKey.value}`,
updatedData
);
await fetchFeatures();
}
function submitVariation(e: Event) {
e.preventDefault();
const form = e.currentTarget as HTMLFormElement;
const formData = new FormData(form);
const entries = Object.keys(this.activeVariation.variables).map((key) => {
let value = formData.get(key) as any;
const type = this.variableTypes[key];
if (type === 'Boolean') value = Boolean(value);
else if (type === 'Number') value = Number(value);
return [key, value];
});
const variables = Object.fromEntries(entries);
updateFeature({ variables });
}
return {
data,
activeFeatureKey,
activeVariationKey,
updateFeature,
submitVariation,
};
},
computed: {
activeFeature(): Feature | undefined {
return this.data.find((item) => item.key === this.activeFeatureKey);
},
activeVariation(): Variation | undefined {
return this.activeFeature?.variations.find(
(item) => item.key === this.activeVariationKey
);
},
variableTypes(): Record<string, any> {
const variableTypes = {};
this.data.forEach((item) => {
item.variables.forEach((variable) => {
variableTypes[variable.key] = variable.type;
});
});
return variableTypes;
},
},
});
</script>
<style scoped>
.container {
padding: 24px;
color: black;
}
.container h2 {
font-size: 24px;
font-weight: 600;
}
.container.has-header {
padding: 0 12px;
}
.container button {
margin-block: 24px;
text-align: left;
width: 100%;
}
.features {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
}
.features button {
font-size: 18px;
}
.active-feature h2 {
font-size: 18px;
margin-block: 8px;
}
.active-feature p {
margin-block: 8px;
}
.save-button {
background-color: gray;
border: 1px solid gray;
border-radius: 6px;
color: white;
max-width: fit-content;
padding: 4px;
}
</style>
From the directus-extension-devcyle
directory, run npm run build
.
Test the Panel
Go into your Directus instance and select the “Insights” Module. Hit the "+ Dashboard" button to add a new dashboard. We'll use this to test out our custom DevCycle panel.
Check for our extension from the list of available panels. In the input fields, enter your DevCycle project name, e.g “my-first-project” and save.
Now, you can update the features within the panel, and you’ll see them reflected in DevCycle. Some next steps could include:
- Implementing a
DELETE
endpoint and functionality that deletes features no longer needed. - Improve the styling of the panel to further enhance the user experience
Got questions? Feel free to join our Discord community and ask any questions you may have.