Skip to content
On this page

Directus Cloud

Everything you need to start building. Provisioned in 90 seconds. Starting at $15/month.

Get Started

Developer Blog

Build a DevCycle Feature Flag Control Panel in Directus

Published January 3rd, 2024

Written By
Esther Agbaje
Esther Agbaje
Developer Advocate

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.

Directus Feature Flag Panel

Getting Started

To complete this tutorial, you'll need the following:

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:

  1. Task Time Tracking
  2. Real-Time Collaboration
  3. Custom Workflows
Sample Variables

Feature: Task Time Tracking

VariablesTypeVariation OnVariation Off
task-time-tracking-enabledBooelantruefalse
time-tracking-rolesStringadministratorcollaborator
time-tracking-max-hoursNumber2010

Feature: Real-Time Collaboration

VariablesTypeVariation OnVariation Off
real-time-collaboration-enabledBooelantruefalse
collaboration-file-sharingBooelantruefalse
collaboration-editing-permissionsStringfulllimited

Feature: Custom Workflows

VariablesTypeVariation OnVariation Off
custom-workflows-enabledBooelantruefalse
workflow-templates-enabledBooelantruefalse
workflow-step-limitsNumber105

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:

bash
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:

yml
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:

ts
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.

bash
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:

ts
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:

ts
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.

ts
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.

ts
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.

ts
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.

ts
<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:

ts
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

ts
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:

ts
<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
ts
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.

ts
<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.

Test the Directus Feature Flag Panel

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.

What do you think?

How helpful was this article?